LLM index: /llms.txt
Composable user and assistant chat turns via Message, MessageContent, MessageMarkdown (Streamdown), MessageAvatar, MessageActions, and Attachments.
What is the capital of France?
The capital of France is Paris.
"use client";
import {
Message,
MessageContent,
MessageMarkdown,
MessageStack,
} from "@/components/nexus-ui/message";
const MessageDefault = () => {
return (
<div className="flex w-full flex-col items-center justify-center gap-6">
<Message from="user">
<MessageStack>
<MessageContent>
<MessageMarkdown>What is the capital of France?</MessageMarkdown>
</MessageContent>
</MessageStack>
</Message>
<Message from="assistant">
<MessageStack>
<MessageContent>
<MessageMarkdown>The capital of France is Paris.</MessageMarkdown>
</MessageContent>
</MessageStack>
</Message>
</div>
);
};
export default MessageDefault;
Installation
npx shadcn@latest add @nexus-ui/messagepnpm dlx shadcn@latest add @nexus-ui/messageyarn dlx shadcn@latest add @nexus-ui/messagebunx shadcn@latest add @nexus-ui/messageInstall the following dependencies:
npx shadcn@latest add button avatar tooltip kbd && npm install streamdown @streamdown/cjk @streamdown/code @streamdown/math @streamdown/mermaid radix-ui @radix-ui/react-slot @hugeicons/react @hugeicons/core-free-icons hastpnpm dlx shadcn@latest add button avatar tooltip kbd && pnpm add streamdown @streamdown/cjk @streamdown/code @streamdown/math @streamdown/mermaid radix-ui @radix-ui/react-slot @hugeicons/react @hugeicons/core-free-icons hastyarn dlx shadcn@latest add button avatar tooltip kbd && yarn add streamdown @streamdown/cjk @streamdown/code @streamdown/math @streamdown/mermaid radix-ui @radix-ui/react-slot @hugeicons/react @hugeicons/core-free-icons hastbunx shadcn@latest add button avatar tooltip kbd && bun add streamdown @streamdown/cjk @streamdown/code @streamdown/math @streamdown/mermaid radix-ui @radix-ui/react-slot @hugeicons/react @hugeicons/core-free-icons hastCopy and paste the following code into your project.
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { Streamdown } from "streamdown";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { CodeBlock } from "@/components/nexus-ui/codeblock";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/lib/utils";
const streamdownPlugins = { cjk, code, math, mermaid } as const;
const messageMarkdownProseClasses = [
"prose max-w-none text-primary font-normal text-sm leading-6.5",
// headings
"prose-headings:font-[450] prose-headings:leading-5.5 prose-h2:tracking-[-0.45px] prose-headings:mb-4 prose-headings:mt-6 prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-h3:leading-4.5 prose-h3:tracking-[-0.4px] prose-h4:text-sm prose-h5:text-xs prose-h6:text-xs",
// heading links
"prose-headings:[&_a]:no-underline prose-headings:[&_a]:shadow-none prose-headings:[&_a]:text-inherit",
// body text
"prose-p:mb-1 prose-p:mt-4",
// lead
"prose-lead:text-primary",
// links
"[&_[data-streamdown=link]]:text-foreground [&_[data-streamdown=link]]:font-normal [&_[data-streamdown=link]]:underline [&_[data-streamdown=link]]:underline-offset-2",
// strong
"[&_[data-streamdown=strong]]:text-foreground [&_[data-streamdown=strong]]:font-[550]",
// lists
"prose-li:my-[-0.5px] prose-li:marker:text-muted-foreground/50 prose-ul:my-0 prose-ol:my-0 prose-ol:pl-3",
] as const;
type MessageFrom = "user" | "assistant";
type MessageContextValue = {
from: MessageFrom;
};
const MessageContext = React.createContext<MessageContextValue | null>(null);
function useMessageContext() {
return React.useContext(MessageContext);
}
type MessageProps = React.HTMLAttributes<HTMLDivElement> & {
from: MessageFrom;
};
const Message = React.forwardRef<HTMLDivElement, MessageProps>(function Message(
{
className,
from,
children,
"aria-label": ariaLabelProp,
"aria-labelledby": ariaLabelledBy,
...props
},
ref,
) {
const ariaLabel =
ariaLabelProp ??
(ariaLabelledBy == null
? from === "user"
? "User message"
: "Assistant message"
: undefined);
return (
<MessageContext.Provider value={{ from }}>
<div
ref={ref}
data-slot="message"
role="article"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={cn(
"group/message flex w-full max-w-[90%] items-start gap-2",
from === "user" ? "ms-auto" : "me-auto",
className,
)}
{...props}
>
{children}
</div>
</MessageContext.Provider>
);
});
type MessageStackProps = React.HTMLAttributes<HTMLDivElement>;
function MessageStack({ className, ...props }: MessageStackProps) {
const ctx = useMessageContext();
const from = ctx?.from ?? "assistant";
return (
<div
data-slot="message-stack"
className={cn(
"flex w-full flex-col gap-2",
from === "user" ? "items-end" : "items-start",
className,
)}
{...props}
/>
);
}
type MessageContentProps = React.HTMLAttributes<HTMLDivElement>;
function MessageContent({ className, ...props }: MessageContentProps) {
const ctx = useMessageContext();
const from = ctx?.from ?? "assistant";
return (
<div
data-slot="message-content"
className={cn(
"rounded-[20px] text-sm leading-6.5 text-primary",
from === "user"
? "w-fit bg-secondary px-4 py-2"
: "mb-1 w-full bg-transparent px-2",
className,
)}
{...props}
/>
);
}
type MessageMarkdownProps = React.ComponentProps<typeof Streamdown>;
function MessageMarkdown({
className,
components,
...props
}: MessageMarkdownProps) {
const mergedComponents = React.useMemo(
() => {
const defaultComponents = {
code: CodeBlock,
inlineCode: ({
children,
className,
...props
}: React.HTMLAttributes<HTMLElement>) => (
<code
className={cn(
"rounded-md border-none bg-muted px-1.5 py-0.5 font-mono text-xs font-[450]",
className,
)}
data-slot="message-markdown-inline-code"
{...props}
>
{children}
</code>
),
table: (props: React.HTMLAttributes<HTMLTableElement>) => (
<div
data-slot="message-markdown-table-wrap"
className={[
"my-6 prose-no-margin overflow-hidden rounded-2xl border border-border bg-muted dark:border-accent dark:bg-background",
"[&_tbody_tr:first-child_td:first-child]:rounded-ss-xl",
"[&_tbody_tr:first-child_td:last-child]:rounded-se-xl",
"[&_tbody_tr:last-child_td:first-child]:rounded-es-xl",
"[&_tbody_tr:last-child_td:last-child]:rounded-ee-xl",
].join(" ")}
>
<table
data-slot="message-markdown-table"
className="w-full border-separate border-spacing-0 border-none bg-muted text-sm dark:bg-background"
{...props}
/>
</div>
),
th: (props: React.ThHTMLAttributes<HTMLTableCellElement>) => (
<th
data-slot="message-markdown-th"
className="border-none px-5 py-2 text-start text-[13px] font-normal! text-muted-foreground! dark:bg-background"
{...props}
/>
),
td: (props: React.TdHTMLAttributes<HTMLTableCellElement>) => (
<td
data-slot="message-markdown-td"
className="border-0 border-accent bg-card px-5 py-3 text-[13px] text-primary dark:bg-card [tr:not(:first-child)_&]:border-t"
{...props}
/>
),
};
return {
...(defaultComponents as object),
...((components ?? {}) as object),
};
},
[components],
);
return (
<Streamdown
data-slot="message-markdown"
className={cn(
...messageMarkdownProseClasses,
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className,
)}
components={mergedComponents as MessageMarkdownProps["components"]}
shikiTheme={["github-light", "github-dark"]}
plugins={streamdownPlugins}
{...props}
/>
);
}
type MessageActionsProps = React.HTMLAttributes<HTMLDivElement>;
function MessageActions({ className, ...props }: MessageActionsProps) {
const ctx = useMessageContext();
const from = ctx?.from ?? "assistant";
return (
<div
data-slot="message-actions"
className={cn(
"flex w-full",
from === "user" ? "justify-end" : "justify-start",
className,
)}
{...props}
/>
);
}
type MessageActionGroupProps = React.HTMLAttributes<HTMLDivElement>;
function MessageActionGroup({ className, ...props }: MessageActionGroupProps) {
return (
<div
data-slot="message-action-group"
className={cn("flex items-center gap-1", className)}
{...props}
/>
);
}
type MessageActionProps = React.HTMLAttributes<HTMLDivElement> & {
asChild?: boolean;
tooltip?:
| string
| {
content?: string;
side?: "top" | "right" | "bottom" | "left";
shortcut?: string;
};
};
function MessageAction({
asChild = false,
tooltip,
...props
}: MessageActionProps) {
const Comp = asChild ? Slot : "div";
const { content, side, shortcut } =
typeof tooltip === "string" ? { content: tooltip } : tooltip ?? {};
if (!content) {
return <Comp data-slot="message-action" {...props} />;
}
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Comp data-slot="message-action" {...props} />
</TooltipTrigger>
<TooltipContent className="rounded-full" side={side}>
{content}
{shortcut ? <Kbd className="rounded-md!">{shortcut}</Kbd> : null}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export type MessageAvatarProps = {
src: string;
alt?: string;
fallback?: React.ReactNode;
delayMs?: React.ComponentProps<typeof AvatarFallback>["delayMs"];
size?: React.ComponentProps<typeof Avatar>["size"];
className?: string;
};
function MessageAvatar({
src,
alt = "",
fallback,
delayMs,
size,
className,
}: MessageAvatarProps) {
return (
<Avatar
data-slot="message-avatar"
size={size}
className={cn("size-7 shrink-0", className)}
>
<AvatarImage
data-slot="message-avatar-image"
src={src}
alt={alt}
className="my-0!"
/>
<AvatarFallback
data-slot="message-avatar-fallback"
delayMs={delayMs}
className="my-0! shrink-0"
>
{fallback}
</AvatarFallback>
</Avatar>
);
}
export {
Message,
MessageStack,
MessageContent,
MessageMarkdown,
MessageActions,
MessageActionGroup,
MessageAction,
MessageAvatar,
};
"use client";
/**
* Streamdown `components.code` for fenced blocks (Shiki via {@link @streamdown/code}).
* Installed together with **Message** (`@nexus-ui/message`): same registry item as `message.tsx`, not a separate add.
*
* Nexus chrome: title row (optional via **`showTitleRow`**), copy, bordered card, scroll viewport.
* Set `components.inlineCode` per
* [Streamdown](https://streamdown.ai/docs/components#inline-code).
*/
import { HugeiconsIcon } from "@hugeicons/react";
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { code as codeHighlighter } from "@streamdown/code";
import type { Element as HastElement } from "hast";
import {
type ComponentProps,
type CSSProperties,
type DetailedHTMLProps,
type HTMLAttributes,
type MouseEventHandler,
type ReactNode,
isValidElement,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
BundledLanguage,
CodeHighlighterPlugin,
ExtraProps,
} from "streamdown";
import { StreamdownContext, useIsCodeFenceIncomplete } from "streamdown";
import { cn } from "@/lib/utils";
// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------
type MarkdownCodeElementProps = DetailedHTMLProps<
HTMLAttributes<HTMLElement>,
HTMLElement
> &
ExtraProps;
/** Streamdown `components.code` props: markdown element props plus Nexus chrome overrides. */
export type CodeBlockProps = MarkdownCodeElementProps & {
/** Language label + copy in a header row. When false, copy floats top-right. Defaults to true when omitted. */
showTitleRow?: boolean;
};
type HighlightResult = NonNullable<
ReturnType<CodeHighlighterPlugin["highlight"]>
>;
type CodeBlockPreProps = Omit<ComponentProps<"pre">, "children"> & {
result: HighlightResult;
language: string;
lineNumbers?: boolean;
/** 1-based first line (`startLine=`); uses `app/global.css` `code .line`. */
lineNumbersStart?: number;
};
type CodeBlockFencedViewProps = {
code: string;
language: string;
className?: string;
isIncomplete?: boolean;
startLine?: number;
lineNumbers?: boolean;
codePlugin?: CodeHighlighterPlugin;
showTitleRow?: boolean;
};
// -----------------------------------------------------------------------------
// Constants
// -----------------------------------------------------------------------------
const LANGUAGE_REGEX = /language-([^\s]+)/;
const START_LINE_PATTERN = /startLine=(\d+)/;
const NO_LINE_NUMBERS_PATTERN = /\bnoLineNumbers\b/;
// -----------------------------------------------------------------------------
// Utilities (pure)
// -----------------------------------------------------------------------------
function sameNodePosition(prev?: HastElement, next?: HastElement): boolean {
if (!(prev?.position || next?.position)) return true;
if (!(prev?.position && next?.position)) return false;
const ps = prev.position.start;
const ns = next.position.start;
const pe = prev.position.end;
const ne = next.position.end;
return (
ps?.line === ns?.line &&
ps?.column === ns?.column &&
pe?.line === ne?.line &&
pe?.column === ne?.column
);
}
function extractCodeString(children: ReactNode): string {
if (
isValidElement(children) &&
children.props &&
typeof children.props === "object" &&
"children" in children.props &&
typeof (children.props as { children?: unknown }).children === "string"
) {
return (children.props as { children: string }).children;
}
if (typeof children === "string") return children;
return "";
}
function getMetastring(node?: HastElement): string | undefined {
const raw = node?.properties?.metastring;
return typeof raw === "string" ? raw : undefined;
}
function trimTrailingNewlines(str: string): string {
let end = str.length;
while (end > 0 && str[end - 1] === "\n") end--;
return str.slice(0, end);
}
function buildRawHighlightResult(trimmed: string): HighlightResult {
return {
bg: "transparent",
fg: "inherit",
tokens: trimmed.split("\n").map((line) => [
{
content: line,
color: "inherit",
bgColor: "transparent",
htmlStyle: {},
offset: 0,
},
]),
};
}
function parseRootStyle(rootStyle: string): Record<string, string> {
const style: Record<string, string> = {};
for (const decl of rootStyle.split(";")) {
const idx = decl.indexOf(":");
if (idx > 0) {
const prop = decl.slice(0, idx).trim();
const val = decl.slice(idx + 1).trim();
if (prop && val) style[prop] = val;
}
}
return style;
}
// -----------------------------------------------------------------------------
// Primitives: copy control
// -----------------------------------------------------------------------------
const COPIED_RESET_MS = 1500;
function useCopyButton(
onCopy: () => void | Promise<void>,
): [checked: boolean, onClick: MouseEventHandler] {
const [checked, setChecked] = useState(false);
const callbackRef = useRef(onCopy);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
callbackRef.current = onCopy;
}, [onCopy]);
const onClick = useCallback<MouseEventHandler>(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
void Promise.resolve(callbackRef.current()).then(() => {
setChecked(true);
timeoutRef.current = setTimeout(() => {
setChecked(false);
}, COPIED_RESET_MS);
});
}, []);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
return [checked, onClick];
}
function CodeBlockCopyButton({
text,
showGlow = false,
className,
}: {
text: string;
showGlow?: boolean;
className?: string;
}) {
const [checked, onClick] = useCopyButton(() => {
void navigator.clipboard.writeText(text);
});
return (
<div className="relative">
{showGlow ? (
<div
className={cn(
"pointer-events-none absolute top-1/2 left-1/2 z-0 size-13.5 -translate-x-1/2 -translate-y-1/2 rounded-l-full rounded-tr-full bg-linear-to-l",
"from-card from-70% to-card/0",
)}
/>
) : null}
<button
type="button"
data-checked={checked || undefined}
className={cn(
"relative flex size-7 cursor-pointer items-center justify-center rounded-lg text-ring hover:text-primary",
className,
)}
aria-label={checked ? "Copied" : "Copy code"}
onClick={onClick}
>
<span className="flex size-5 items-center justify-center">
{checked ? (
<HugeiconsIcon
icon={Tick02Icon}
strokeWidth={1.75}
className="size-4.5"
/>
) : (
<HugeiconsIcon
icon={Copy01Icon}
strokeWidth={1.75}
className="size-4"
/>
)}
</span>
</button>
</div>
);
}
// -----------------------------------------------------------------------------
// Primitives: title row
// -----------------------------------------------------------------------------
function CodeBlockTitleRow({
title,
copyText,
}: {
title: string;
copyText: string;
}) {
return (
<div className="flex h-9.5 items-center gap-2 px-4 text-muted-foreground">
<figcaption className="flex-1 truncate text-[13px] lowercase">
{title}
</figcaption>
<div className="-me-2 flex shrink-0 items-center">
<CodeBlockCopyButton showGlow={false} text={copyText} />
</div>
</div>
);
}
// -----------------------------------------------------------------------------
// Primitives: scroll viewport
// -----------------------------------------------------------------------------
function CodeBlockViewport({
/** `top` when a title row sits above; `all` when the viewport is the only block body. */
rounding = "top",
children,
}: {
rounding?: "top" | "all";
children: React.ReactNode;
}) {
return (
<div
className={cn(
"no-scrollbar overflow-auto overscroll-x-none px-4 py-3.5 text-sm leading-6",
rounding === "top" ? "rounded-t-xl" : "rounded-xl",
"bg-card",
)}
>
{children}
</div>
);
}
// -----------------------------------------------------------------------------
// Primitives: figure chrome (outer shell + inner card)
// -----------------------------------------------------------------------------
function CodeBlockFigureChrome({
className,
language,
isIncomplete,
showTitleRow,
title,
copyText,
children,
}: {
className?: string;
language: string;
isIncomplete?: boolean;
showTitleRow: boolean;
title: string;
copyText: string;
children: React.ReactNode;
}) {
return (
<figure
className={cn(
"my-4 rounded-xl border border-border dark:border-accent",
showTitleRow ? "bg-muted dark:bg-background" : "dark:bg-card",
"not-prose relative w-full overflow-hidden text-[13px] font-[450]",
className,
)}
data-incomplete={isIncomplete || undefined}
data-language={language}
data-slot="nexus-code-block"
dir="ltr"
tabIndex={-1}
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 200px" }}
>
{showTitleRow ? (
<CodeBlockTitleRow copyText={copyText} title={title} />
) : (
<div className="absolute top-3 right-3 z-20">
<CodeBlockCopyButton showGlow text={copyText} />
</div>
)}
<CodeBlockViewport rounding={showTitleRow ? "top" : "all"}>
{children}
</CodeBlockViewport>
</figure>
);
}
// -----------------------------------------------------------------------------
// Primitives: highlighted token lines → pre/code
// -----------------------------------------------------------------------------
function CodeBlockTokenSpan({
token,
}: {
token: HighlightResult["tokens"][number][number];
}) {
const tokenStyle: Record<string, string> = {};
let hasBg = Boolean(token.bgColor);
if (token.color) tokenStyle["--sdm-c"] = token.color;
if (token.bgColor) tokenStyle["--sdm-tbg"] = token.bgColor;
if (token.htmlStyle) {
for (const [key, value] of Object.entries(token.htmlStyle)) {
if (value == null) continue;
if (key === "color") {
tokenStyle["--sdm-c"] = String(value);
} else if (key === "background-color") {
tokenStyle["--sdm-tbg"] = String(value);
hasBg = true;
} else {
tokenStyle[key] = String(value);
}
}
}
const htmlAttrs = (
token as { htmlAttrs?: Record<string, string | undefined> }
).htmlAttrs;
return (
<span
className={cn(
"text-(--sdm-c,inherit)",
"dark:text-(--shiki-dark,var(--sdm-c,inherit))",
hasBg && "bg-(--sdm-tbg)",
hasBg && "dark:bg-(--shiki-dark-bg,var(--sdm-tbg))",
)}
style={tokenStyle as CSSProperties}
{...htmlAttrs}
>
{token.content}
</span>
);
}
const CodeBlockPre = memo(
function CodeBlockPre({
result,
language,
className,
lineNumbers = true,
lineNumbersStart = 1,
...rest
}: CodeBlockPreProps) {
const preStyle = useMemo(() => {
const style: Record<string, string> = {};
if (result.bg) style["--sdm-bg"] = result.bg;
if (result.fg) style["--sdm-fg"] = result.fg;
if (result.rootStyle && typeof result.rootStyle === "string") {
Object.assign(style, parseRootStyle(result.rootStyle));
}
return style as CSSProperties;
}, [result.bg, result.fg, result.rootStyle]);
return (
<pre
className={cn(
"w-max min-w-full bg-(--sdm-bg,inherit) *:flex *:flex-col dark:bg-(--shiki-dark-bg,var(--sdm-bg,inherit))",
className,
)}
data-language={language}
data-slot="nexus-code-block-body"
style={preStyle}
{...rest}
>
<code
style={
lineNumbers
? ({
counterSet: `line ${Number(lineNumbersStart) - 1}`,
} satisfies CSSProperties)
: undefined
}
>
{result.tokens.map((row, rowIndex) => (
<span
key={rowIndex}
className={lineNumbers ? "line block" : "block"}
>
{row.length === 0 || (row.length === 1 && row[0].content === "")
? "\n"
: row.map((token, tokenIndex) => (
<CodeBlockTokenSpan key={tokenIndex} token={token} />
))}
</span>
))}
</code>
</pre>
);
},
(prev, next) =>
prev.result === next.result &&
prev.language === next.language &&
prev.className === next.className &&
prev.lineNumbers === next.lineNumbers &&
prev.lineNumbersStart === next.lineNumbersStart,
);
CodeBlockPre.displayName = "CodeBlockPre";
// -----------------------------------------------------------------------------
// Primitives: async Shiki highlight + pre
// -----------------------------------------------------------------------------
function CodeBlockShikiPre({
code,
language,
raw,
className,
lineNumbers,
lineNumbersStart,
codePlugin,
}: {
code: string;
language: string;
raw: HighlightResult;
className?: string;
lineNumbers?: boolean;
lineNumbersStart?: number;
codePlugin: CodeHighlighterPlugin;
}) {
const { shikiTheme } = useContext(StreamdownContext);
const [result, setResult] = useState<HighlightResult>(raw);
useEffect(() => {
codePlugin.highlight(
{
code,
language: language as BundledLanguage,
themes: shikiTheme,
},
(highlighted) => setResult(highlighted),
);
}, [code, language, shikiTheme, codePlugin, raw]);
return (
<CodeBlockPre
className={className}
language={language}
lineNumbers={lineNumbers}
lineNumbersStart={lineNumbersStart}
result={result}
/>
);
}
// -----------------------------------------------------------------------------
// Composed: CodeBlockFencedView (string + meta → chrome + Shiki)
// -----------------------------------------------------------------------------
function CodeBlockFencedView({
code,
language,
className,
isIncomplete,
startLine,
lineNumbers = true,
codePlugin = codeHighlighter,
showTitleRow: showTitleRowProp,
}: CodeBlockFencedViewProps) {
const showTitleRow = showTitleRowProp ?? true;
const trimmed = useMemo(() => trimTrailingNewlines(code), [code]);
const raw = useMemo(() => buildRawHighlightResult(trimmed), [trimmed]);
const title = (language || "code").toLowerCase();
return (
<CodeBlockFigureChrome
className={className}
copyText={trimmed}
isIncomplete={isIncomplete}
language={language}
showTitleRow={showTitleRow}
title={title}
>
<CodeBlockShikiPre
code={trimmed}
codePlugin={codePlugin}
language={language}
lineNumbers={lineNumbers}
lineNumbersStart={startLine ?? 1}
raw={raw}
/>
</CodeBlockFigureChrome>
);
}
// -----------------------------------------------------------------------------
// Export: Streamdown `components.code`
// -----------------------------------------------------------------------------
export const CodeBlock = memo(
function CodeBlock({
node,
className,
children,
showTitleRow,
}: CodeBlockProps) {
const { lineNumbers: contextLineNumbers } = useContext(StreamdownContext);
const isIncompleteFence = useIsCodeFenceIncomplete();
const match = className?.match(LANGUAGE_REGEX);
const language = match?.[1] ?? "";
const metastring = getMetastring(node);
const startLineMatch = metastring?.match(START_LINE_PATTERN);
const parsedStart = startLineMatch
? Number.parseInt(startLineMatch[1], 10)
: undefined;
const startLine =
parsedStart !== undefined && parsedStart >= 1 ? parsedStart : undefined;
const metaNoLineNumbers = metastring
? NO_LINE_NUMBERS_PATTERN.test(metastring)
: false;
const showLineNumbers = !metaNoLineNumbers && contextLineNumbers !== false;
const codeText = extractCodeString(children);
return (
<CodeBlockFencedView
className={className}
code={codeText}
codePlugin={codeHighlighter}
isIncomplete={isIncompleteFence}
language={language}
lineNumbers={showLineNumbers}
showTitleRow={showTitleRow}
startLine={startLine}
/>
);
},
(p, n) =>
p.className === n.className &&
sameNodePosition(p.node, n.node) &&
p.showTitleRow === n.showTitleRow,
);
CodeBlock.displayName = "CodeBlock";
Update import paths to match your project setup.
Usage
import {
Message,
MessageStack,
MessageContent,
MessageMarkdown,
MessageActions,
MessageActionGroup,
MessageAction,
MessageAvatar,
} from "@/components/nexus-ui/message";<Message from="user">
<MessageStack>
<MessageContent>
<MessageMarkdown>Hello</MessageMarkdown>
</MessageContent>
</MessageStack>
<MessageAvatar src="/avatar.png" alt="You" fallback="Y" />
</Message>Examples
With Actions
Use MessageActions and MessageActionGroup for a row of controls. MessageAction supports built-in tooltips via tooltip as either a string or object (content, optional side, optional shortcut).
Tell me about Nexus UI.
Nexus UI is an open-source React component library aimed at AI-powered UIs—chat, streaming, and multimodal flows—built with Tailwind CSS v4 and Radix UI.
"use client";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Copy01Icon,
ThumbsUpIcon,
ThumbsDownIcon,
RepeatIcon,
Edit04Icon,
} from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import {
Message,
MessageAction,
MessageActionGroup,
MessageActions,
MessageContent,
MessageMarkdown,
MessageStack,
} from "@/components/nexus-ui/message";
const MessageWithActions = () => {
return (
<div className="flex w-full flex-col items-center justify-center gap-6">
<Message from="user">
<MessageStack>
<MessageContent>
<MessageMarkdown>Tell me about Nexus UI.</MessageMarkdown>
</MessageContent>
<MessageActions>
<MessageActionGroup>
<MessageAction asChild tooltip={{ content: "Edit", shortcut: "E" }}>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="cursor-pointer rounded-full bg-transparent text-muted-foreground transition-all hover:bg-muted active:scale-97"
>
<HugeiconsIcon
icon={Edit04Icon}
strokeWidth={2.0}
className="size-4"
/>
</Button>
</MessageAction>
<MessageAction asChild tooltip="Copy">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="cursor-pointer rounded-full bg-transparent text-muted-foreground transition-all hover:bg-muted active:scale-97"
>
<HugeiconsIcon
icon={Copy01Icon}
strokeWidth={2.0}
className="size-4"
/>
</Button>
</MessageAction>
</MessageActionGroup>
</MessageActions>
</MessageStack>
</Message>
<Message from="assistant">
<MessageStack>
<MessageContent>
<MessageMarkdown>
Nexus UI is an open-source React component library aimed at AI-powered UIs—chat, streaming, and multimodal flows—built with Tailwind CSS v4 and Radix UI.
</MessageMarkdown>
</MessageContent>
<MessageActions>
<MessageActionGroup>
<MessageAction asChild tooltip="Copy response">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="cursor-pointer rounded-full bg-transparent text-muted-foreground transition-all hover:bg-muted active:scale-97"
>
<HugeiconsIcon
icon={Copy01Icon}
strokeWidth={2.0}
className="size-4"
/>
</Button>
</MessageAction>
<MessageAction
asChild
tooltip={{ content: "Like", side: "top", shortcut: "L" }}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="cursor-pointer rounded-full bg-transparent text-muted-foreground transition-all hover:bg-muted active:scale-97"
>
<HugeiconsIcon
icon={ThumbsUpIcon}
strokeWidth={2.0}
className="size-4"
/>
</Button>
</MessageAction>
<MessageAction asChild tooltip="Dislike">
<Button
type="button"
variant="ghost"
size="icon-sm"
className="cursor-pointer rounded-full bg-transparent text-muted-foreground transition-all hover:bg-muted active:scale-97"
>
<HugeiconsIcon
icon={ThumbsDownIcon}
strokeWidth={2.0}
className="size-4"
/>
</Button>
</MessageAction>
<MessageAction asChild tooltip={{ content: "Regenerate", side: "right" }}>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="cursor-pointer rounded-full bg-transparent text-muted-foreground transition-all hover:bg-muted active:scale-97"
>
<HugeiconsIcon
icon={RepeatIcon}
strokeWidth={2.0}
className="size-4"
/>
</Button>
</MessageAction>
</MessageActionGroup>
</MessageActions>
</MessageStack>
</Message>
</div>
);
};
export default MessageWithActions;
With Avatar
MessageAvatar composes shadcn Avatar / AvatarImage / AvatarFallback — pass src, alt, and optional fallback (and delayMs if needed). Place the avatar after MessageStack for from="user", and before MessageStack for from="assistant".
Hello — can you help me draft an email?
Of course. What tone do you want: formal or friendly?
"use client";
import {
Message,
MessageAvatar,
MessageContent,
MessageMarkdown,
MessageStack,
} from "@/components/nexus-ui/message";
const imgUser = "/assets/user-avatar.avif";
const imgAssistant = "/assets/nexus-avatar.png";
const MessageWithAvatar = () => {
return (
<div className="flex w-full flex-col items-center justify-center gap-6">
<Message from="user">
<MessageStack>
<MessageContent>
<MessageMarkdown>Hello — can you help me draft an email?</MessageMarkdown>
</MessageContent>
</MessageStack>
<MessageAvatar src={imgUser} alt="" fallback="U" className="border border-accent" />
</Message>
<Message from="assistant">
<MessageAvatar src={imgAssistant} alt="" fallback="A" className="border border-accent" />
<MessageStack>
<MessageContent>
<MessageMarkdown>
Of course. What tone do you want: formal or friendly?
</MessageMarkdown>
</MessageContent>
</MessageStack>
</Message>
</div>
);
};
export default MessageWithAvatar;
With Attachments
Render AttachmentList / Attachment inside MessageStack above MessageContent. Use readOnly on Attachment when showing files that were already sent (no remove control or progress). See Attachments for AttachmentMeta and variants.
What do you see in these files?
I can see an image attachment and a text file named notes.txt.
"use client";
import { Attachment, AttachmentList, type AttachmentMeta } from "@/components/nexus-ui/attachments";
import {
Message,
MessageContent,
MessageMarkdown,
MessageStack,
} from "@/components/nexus-ui/message";
const previewUrl =
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=400&fit=crop";
const items: AttachmentMeta[] = [
{
type: "image",
name: "landscape.jpg",
url: previewUrl,
mimeType: "image/jpeg",
},
{ type: "file", name: "notes.txt", mimeType: "text/plain" },
];
const MessageWithAttachments = () => {
return (
<div className="flex w-full flex-col items-center justify-center gap-6">
<Message from="user">
<MessageStack>
<AttachmentList className="justify-end p-0 gap-2">
{items.map((item) => (
<Attachment
key={`${item.name}-${item.type}-${item.mimeType}`}
variant="compact"
attachment={item}
readOnly
className="size-25 rounded-[10px]"
/>
))}
</AttachmentList>
<MessageContent>
<MessageMarkdown>What do you see in these files?</MessageMarkdown>
</MessageContent>
</MessageStack>
</Message>
<Message from="assistant">
<MessageStack>
<MessageContent>
<MessageMarkdown>
I can see an image attachment and a text file named notes.txt.
</MessageMarkdown>
</MessageContent>
</MessageStack>
</Message>
</div>
);
};
export default MessageWithAttachments;
Rich Text (Markdown)
MessageMarkdown renders markdown with Streamdown and shared prose-style classes. Pass a string as children (or a template literal) for headings, lists, code blocks, and links.
Show me markdown features.
Quick tips
You can use bold, italic, inline code, and .
Lists and structure
Unordered:
- Bullet one
- Bullet two
Ordered:
- First step
- Second step
- Third step
Comparison table
| Feature | Notes |
|---|---|
| Tables | GFM-style pipes |
| Code | Fenced blocks and inline |
const answer = 42;
function greet(name: string) { return `Hello, ${name}`;}"use client";
import {
Message,
MessageContent,
MessageMarkdown,
MessageStack,
} from "@/components/nexus-ui/message";
const markdown = `## Quick tips
You can use **bold**, *italic*, inline \`code\`, and [links](https://example.com).
### Lists and structure
Unordered:
- Bullet one
- Bullet two
Ordered:
1. First step
2. Second step
3. Third step
### Comparison table
| Feature | Notes |
| ------- | ----- |
| Tables | GFM-style pipes |
| Code | Fenced blocks and \`inline\` |
\`\`\`typescript
const answer = 42;
function greet(name: string) {
return \`Hello, \${name}\`;
}
\`\`\`
`;
const MessageRichText = () => {
return (
<div className="flex w-full flex-col items-center justify-center gap-6">
<Message from="user">
<MessageStack>
<MessageContent>
<MessageMarkdown>Show me markdown features.</MessageMarkdown>
</MessageContent>
</MessageStack>
</Message>
<Message from="assistant">
<MessageStack>
<MessageContent>
<MessageMarkdown >{markdown}</MessageMarkdown>
</MessageContent>
</MessageStack>
</Message>
</div>
);
};
export default MessageRichText;
Message and Streamdown
How MessageMarkdown uses Streamdown
It is a thin wrapper around Streamdown with the same component props, so you can pass children, className, and other Streamdown options as needed.
Nexus applies shared prose-style className tokens for headings, body text, links, lists, and inline code. Shiki highlights fenced code with github-light and github-dark.
Plugins from @streamdown/code, @streamdown/math, @streamdown/mermaid, and @streamdown/cjk enable code blocks, math, diagrams, and CJK-friendly typography. A few MDX components are overridden—especially tables and codeblocks.
Streamdown parses markdown into HTML that relies on utility classes shipped inside streamdown and those plugin packages. That model works well for streaming assistant output so you do not hand-build the markup.
Fenced Code
MessageMarkdown uses CodeBlock for components.code (@/components/nexus-ui/codeblock). @streamdown/code still handles highlighting—you are only swapping the UI.
Use CodeBlock showTitleRow to show or hide the fenced-block title row (pass it on your components.code renderer). Turn line gutters on or off with Streamdown lineNumbers, which MessageMarkdown forwards like any other Streamdown prop.
@nexus-ui/message ships message.tsx and codeblock.tsx in one install—there is no separate codeblock package.
Tailwind @source after install
Installing Message with the shadcn CLI should merge Tailwind @source lines into the CSS file from components.json so those classes are not purged. With a manual install, that merge does not run by default.
After either path, open your global CSS and confirm the @source entries for streamdown and @streamdown/* are present, and that ../node_modules still resolves to the project root from that file’s folder.
If @source entries for streamdown and @streamdown/* are missing, markdown may render unstyled and code or diagram blocks can break.
Vercel AI SDK Integration
Render useChat messages with Message by reading each UIMessage parts array. Join text parts for MessageMarkdown (streaming updates apply as the SDK appends or grows TextUIPart content).
See Prompt Input for a minimal POST /api/chat route with streamText and toUIMessageStreamResponse. For user turns that include uploads, map file parts to Attachments (or your own preview) in addition to text—Attachments covers sendMessage with files.
Install the AI SDK
npm install ai @ai-sdk/react @ai-sdk/openaiCreate your chat API route
Use the same handler as in the Prompt Input docs: messages: await convertToModelMessages(messages) and return result.toUIMessageStreamResponse().
Map messages to Message
Use isTextUIPart from ai so you only aggregate type: "text" segments. Skip system turns unless you surface them deliberately. Assistant messages can also include reasoning, tool, source, and other part types—extend this loop when you need those in the UI.
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai";
import {
Message,
MessageStack,
MessageContent,
MessageMarkdown,
} from "@/components/nexus-ui/message";
function textFromMessage(message: UIMessage) {
return message.parts.filter(isTextUIPart).map((p) => p.text).join("");
}
export default function ChatThread() {
const { messages } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
});
return (
<div className="flex flex-col gap-4">
{messages
.filter((m) => m.role !== "system")
.map((m) => (
<Message key={m.id} from={m.role === "user" ? "user" : "assistant"}>
<MessageStack>
<MessageContent>
<MessageMarkdown>{textFromMessage(m)}</MessageMarkdown>
</MessageContent>
</MessageStack>
</Message>
))}
</div>
);
}API Reference
Message
Root of one chat turn: row for stack, avatar, and siblings; from sets alignment and is provided in context to MessageStack, MessageContent, and MessageActions.
Prop
Type
MessageStack
Stacks bubble, attachments, and actions in a column; cross-axis alignment follows from on Message (user vs assistant).
Prop
Type
MessageContent
Wraps the message body (e.g. markdown inside). User turns get a filled bubble; assistant turns stay visually light on the thread—both follow from on Message.
Prop
Type
MessageMarkdown
Renders markdown with Streamdown. Props are forwarded to Streamdown; values you pass replace the same keys on the underlying component (e.g. a new plugins object replaces the default bundle).
Commonly used options:
Prop
Type
For remend, remarkPlugins, rehypePlugins, linkSafety, animated, caret, and the full prop list, see the Streamdown configuration docs.
MessageActions
A flex container for action buttons. Default justify-end (user) or justify-start (assistant); override with className (e.g. justify-between) for multiple groups.
Prop
Type
MessageActionGroup
Groups related action buttons together with a horizontal layout.
Prop
Type
MessageAction
A wrapper for individual action buttons. Supports polymorphism via asChild and optional built-in tooltip rendering.
Prop
Type
MessageAvatar
shadcn Avatar with src / alt / optional fallback. Sibling to MessageStack in Message: after the stack for user, before for assistant.
Prop
Type