17 KiB
Chat & Messaging
Components for conversation and chat UI. Compose these instead of hand-rolling bubbles, scroll containers, dividers, or attachment cards.
Install: npx shadcn@latest add message-scroller message bubble attachment marker trace code-block
The same component names and props ship for both base and radix; only
composition differs (render vs asChild). See base-vs-radix.md.
Contents
- Scrollable threads use MessageScroller
- Message rows use Message
- Message surfaces use Bubble
- Attachments use Attachment
- System notes and dividers use Marker
- Assistant activity uses Trace
- Code and tool output use CodeBlock
- Streaming, anchoring, and jump-to-latest are built in
- Escape hatch: the scroller hooks
Scrollable threads use MessageScroller
A conversation that scrolls, follows new messages, restores position, or jumps
to a message uses MessageScroller. Don't build a raw overflow container with
manual scroll wiring, and don't reach for ScrollArea.
The parts nest in a fixed order. Every direct child of the content is wrapped in
a MessageScrollerItem so the scroller can measure, anchor, preserve position,
track visibility, and jump to it. MessageScrollerButton sits inside
MessageScroller, after the viewport.
Incorrect:
// Hand-rolled scroll container with manual stick-to-bottom logic.
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-6 p-4">
{messages.map((m) => (
<ChatMessage key={m.id} message={m} />
))}
</div>
</div>
Correct:
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
<Message align={message.role === "user" ? "end" : "start"}>
{/* ...message content... */}
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
Message rows use Message
Message lays out a single row: avatar, header, content, footer, with
alignment. Group consecutive rows from one sender with MessageGroup. Don't
rebuild the row from flex divs.
align="end" is the current user's side; align="start" is everyone else.
<Message align="start">
<MessageAvatar>
<Avatar>
<AvatarImage src={sender.avatar} alt={sender.name} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent>
<MessageHeader>{sender.name}</MessageHeader>
<Bubble>
<BubbleContent>{text}</BubbleContent>
</Bubble>
<MessageFooter>{time}</MessageFooter>
</MessageContent>
</Message>
Message surfaces use Bubble
The colored message surface is Bubble + BubbleContent, never a styled div
with bg-muted / bg-primary and hand-managed corners.
variant:default,secondary,muted,tinted,outline,ghost,destructive.align:startorend(matches theMessageside).
BubbleReactions renders the reaction cluster. side (top | bottom) and
align (start | end) position it against the bubble. Don't lay reactions out
with absolutely-positioned Badges.
Incorrect:
<div className="w-fit rounded-2xl bg-primary px-3 py-2 text-primary-foreground">
{text}
</div>
Correct:
<Bubble variant="default" align="end">
<BubbleContent>{text}</BubbleContent>
<BubbleReactions side="bottom" align="end">
<Badge variant="secondary">👍 2</Badge>
</BubbleReactions>
</Bubble>
Attachments use Attachment
File and image attachments use Attachment, not Item or a custom card. It
carries upload state, so wire state to the real status rather than rendering a
separate spinner.
state:idle,uploading,processing,error,done.uploadingandprocessingapply theshimmeranimation to the title automatically.size:default,sm,xs.orientation:horizontal,vertical.- Use
AttachmentGroupto lay out several attachments in a scrolling row.
<Attachment state="done">
<AttachmentMedia variant="icon">
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>homepage-feedback.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction>
<DownloadIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
For an image, use <AttachmentMedia variant="image"> with an img child.
System notes and dividers use Marker
Status lines ("Sarah joined the conversation"), date dividers ("Today"), and
labeled separators are Marker, not a Separator plus a centered span.
variant:default(plain row),separator(centered label with rules on each side),border(bottom-bordered row).MarkerIconholds a leading icon;MarkerContentholds the label.
Incorrect:
<div className="flex items-center gap-3 py-2">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground">Today</span>
<Separator className="flex-1" />
</div>
Correct:
<Marker variant="separator">
<MarkerContent>Today</MarkerContent>
</Marker>
Assistant activity uses Trace
What the assistant did or is doing (reasoning, tool calls, chain of thought,
steps, sources, file reads) is a TraceLine in a Trace, not a hand-rolled
Collapsible or a Marker. One line reports one thing; it may have detail.
TraceLineis always the line: aTraceIconand aTraceTitle, then an optionalTraceDescriptionfor a count, a duration or a diff stat.statusgoes on theTraceLine. Never hand-roll a span for secondary text.- Behavior wraps the line. To make a line do something, wrap it in that thing's
trigger:
- nothing to open: the
TraceLinealone; - expands:
TraceCollapsible>TraceCollapsibleTrigger>TraceLine, thenTraceCollapsibleContent. The trigger adds its own chevron; - link:
<a href>>TraceLine; - overlay:
<SheetTrigger>>TraceLine(Dialog, Drawer and Popover the same). Aria:<DialogTrigger><Button>>TraceLine, with React Aria'sButton.
- nothing to open: the
- Never make the line the trigger: no
asChildorrender={<TraceLine />}on a trigger. The trigger must stay a real button. - A
TraceCollapsibleTriggeralways has aTraceCollapsibleContent. A tool row is aTraceLineuntil its output arrives, then aTraceCollapsible. - Rows rendered from message parts go in one
Traceper run of parts. A text part or a pending approval ends the run. A heading for a run is plain text above it, or a collapsible summary row. - A pending approval (
approval-requested) is a prompt, not a row. Render it outsideTraceas anItemwith Approve and Deny inItemActions. See /docs/ai-elements/base/confirmation. Once answered, the tool part is a line again. - A summary (sources, a task, a whole turn) is a
TraceCollapsiblewhoseTraceCollapsibleContentholds aTrace. Set the parent line's status and title from its children yourself. statusis"running","error"or"pending"(a plan step that has not started), or omitted. The words agree: Running, Ran, Failed to run. Never dim a line withopacity-*. Map SDK states yourself; never pass one through.- Add a trailing
TraceIcononly for a link (arrow-up-right for a new tab, with sr-only " (opens in a new tab)" in theTraceTitle) or an overlay (chevron-right). - Open state goes on
TraceCollapsible:defaultOpen, oropenwithonOpenChange(aria:defaultExpanded, orisExpandedwithonExpandedChange). - Use
Markerfor notes and dividers between turns, not for activity. - A checkpoint marks a turn the reader can return to. Render a
Markerseparator after that message, with a Button that callssetMessageswith the messages up to and including it. See /docs/ai-elements/base/checkpoint. - A folder of files is nested
Collapsibles, one folder per row, and a file is aButton. See /docs/ai-elements/base/file-tree. A single file the assistant read is still aTraceLine. - A person's turn is a
Messagewithalign="end"and aBubble. The assistant's answer is aMessage. See /docs/ai-elements/base/message. - A thread is those turns in
MessageScroller. The person's turn is thescrollAnchor, andautoScrollfollows the reply. See /docs/ai-elements/base/chat. - A prompt is an
InputGroup: a textarea, an attachment chip, and a submit button. See /docs/ai-elements/base/prompt-input. - A suggestion is a
Buttonthat sends its label withsendMessage({ text }). See /docs/ai-elements/base/suggestion. - A model picker is a
DropdownMenuwith aDropdownMenuRadioGroup, next to a second one for effort. Send the selected ids onsendMessageasbody.modelandbody.effort. See /docs/ai-elements/base/model-selector. - An inline citation is a
HoverCardon a shortButtonin the sentence, from asource-urlpart. See /docs/ai-elements/base/inline-citation. A list of sources after the answer is still Sources. - Context usage is a
HoverCardwithProgress, read from message metadatausage. The window size is the model's limit. See /docs/ai-elements/base/context. - Asked for an AI Elements component such as Tool, Reasoning, Chain of Thought,
Task, Plan, Sources or Subagent? It is a component page built with Trace (for example
/docs/ai-elements/base/tool). Install
traceand copy the composition. Message is one turn (/docs/ai-elements/base/message). Chat is the thread in Message Scroller (/docs/ai-elements/base/chat). Checkpoint is a Marker (/docs/ai-elements/base/checkpoint). File Tree is nested Collapsibles (/docs/ai-elements/base/file-tree). Confirmation is an Item (/docs/ai-elements/base/confirmation). Prompt Input is an Input Group (/docs/ai-elements/base/prompt-input). Suggestion is a Button (/docs/ai-elements/base/suggestion). Model Selector is a Dropdown Menu (/docs/ai-elements/base/model-selector). Inline Citation is a Hover Card (/docs/ai-elements/base/inline-citation). Context is a Hover Card and Progress (/docs/ai-elements/base/context).
Incorrect:
const [open, setOpen] = useState(true)
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="text-sm text-muted-foreground">
{isStreaming ? "Thinking..." : "Thought for a few seconds"}
</CollapsibleTrigger>
<CollapsibleContent>{part.text}</CollapsibleContent>
</Collapsible>
Correct:
<TraceCollapsible>
<TraceCollapsibleTrigger>
<TraceLine status={running ? "running" : undefined}>
<TraceIcon>
<BrainIcon />
</TraceIcon>
<TraceTitle>{running ? "Thinking" : "Thought"}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>{part.text}</TraceCollapsibleContent>
</TraceCollapsible>
Incorrect: a tool row whose output may not exist yet.
<TraceCollapsible>
<TraceCollapsibleTrigger>
<TraceLine status={status}>…</TraceLine>
</TraceCollapsibleTrigger>
{output && <TraceCollapsibleContent>{output}</TraceCollapsibleContent>}
</TraceCollapsible>
Correct: a TraceLine until there is something to open.
function ToolRow({
icon,
title,
status,
output,
}: {
icon: React.ReactNode
title: string
status?: "running" | "error"
output?: string
}) {
const line = (
<TraceLine status={status}>
<TraceIcon>{icon}</TraceIcon>
<TraceTitle>{title}</TraceTitle>
</TraceLine>
)
if (output === undefined) {
return line
}
return (
<TraceCollapsible>
<TraceCollapsibleTrigger>{line}</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<CodeBlock size="sm">
<CodeBlockContent wrap className="max-h-64">
{output}
</CodeBlockContent>
</CodeBlock>
</TraceCollapsibleContent>
</TraceCollapsible>
)
}
Incorrect: the trigger renders as the line, so it is a span.
<Sheet>
<SheetTrigger asChild>
<TraceLine>…</TraceLine>
</SheetTrigger>
</Sheet>
Correct: sources, each link wrapping its own line.
<TraceCollapsible>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<GlobeIcon />
</TraceIcon>
<TraceTitle>Used {sources.length} sources</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
{sources.map((source) => (
<a key={source.url} href={source.url} target="_blank" rel="noreferrer">
<TraceLine>
<TraceIcon>
<GlobeIcon />
</TraceIcon>
<TraceTitle>
{source.title}
<span className="sr-only"> (opens in a new tab)</span>
</TraceTitle>
<TraceIcon>
<ArrowUpRightIcon />
</TraceIcon>
</TraceLine>
</a>
))}
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
Code and tool output use CodeBlock
Code, terminal output, tool input and output, logs, stack traces and diffs are
a CodeBlock, never a hand-rolled <pre>.
CodeBlockContenttakes a string. Addwrapfor tool output and errors,lineNumbersfor source, and amax-h-*class to cap the height.- Errors use
variant="destructive"onCodeBlock. Don't recolor the block. - For diffs and highlighted lines, map each line to a
CodeBlockLinewithvariant="added","removed"or"highlighted". CodeBlockCopyButtontakes the text to copy asvalue.- Output inside a Trace row is
size="sm"withwrapandmax-h-64, so long output wraps and scrolls inside the row. Docs, Markdown fences and chat answers use the default size. - Header buttons go in
CodeBlockActions. With no header, putCodeBlockActionsdirectly inCodeBlock; it floats and shows on hover. code-blockinstallsuse-highlighted-code. For source code, map the lines ofuseHighlightedCode(code, language)intoCodeBlockLine. In a Server Component, useawait highlightCode(code, language)instead. Don't highlight tool output; pass the string.startLinenumbers a snippet from the middle of a file.
Incorrect:
<pre className="rounded-md bg-muted p-3 font-mono text-xs whitespace-pre-wrap">
{output}
</pre>
Correct:
<CodeBlock>
<CodeBlockHeader>
<CodeBlockTitle>Output</CodeBlockTitle>
<CodeBlockActions>
<CodeBlockCopyButton value={output} />
</CodeBlockActions>
</CodeBlockHeader>
<CodeBlockContent wrap className="max-h-72">
{output}
</CodeBlockContent>
</CodeBlock>
Streaming, anchoring, and jump-to-latest are built in
MessageScroller handles the behavior that chat UIs usually reinvent. Don't
write a useStickToBottom hook, a ResizeObserver, or manual scrollTop math.
- Follow the live edge while streaming.
MessageScrollerProviderwithautoScrollkeeps the view pinned to new content and yields the moment the user scrolls up. Streaming token updates that grow the last message are followed automatically. - Anchor a turn.
scrollAnchoron aMessageScrollerItemmarks the row to hold in view (typically the user's message that started the turn). - Jump to latest.
MessageScrollerButtonappears when the user scrolls away and scrolls back on click.direction="end"(default) ordirection="start". It is a self-managing control, so don't gate it behind your own scroll-position state. - Open a saved transcript without a flash.
defaultScrollPositionapplies after mount. When it is"end"or"last-anchor", the viewport hasdata-pending-scrolluntil that position is applied. The styled viewport hides. For"end"on first paint when messages are in the server HTML, copy this script into the page, not into the primitive. Skip it for"last-anchor"and for client-fetched messages. KeepsuppressHydrationWarningon the viewport. Pass anonceif you use a Content Security Policy.
const scrollToEndScript = `(function () {
var viewport = document.getElementById("messages")
if (!viewport) {
return
}
viewport.scrollTop = viewport.scrollHeight
viewport.removeAttribute("data-pending-scroll")
})()`
<MessageScroller>
<MessageScrollerViewport id="messages" suppressHydrationWarning>
<MessageScrollerContent>{/* transcript */}</MessageScrollerContent>
</MessageScrollerViewport>
<script dangerouslySetInnerHTML={{ __html: scrollToEndScript }} />
<MessageScrollerButton />
</MessageScroller>
For a "thinking…" indicator while the model generates, apply the shimmer
utility to text. Don't author a custom keyframe animation. See
styling.md.
Escape hatch: the scroller hooks
For behavior the parts don't expose, read state from the hooks rather than
re-implementing the scroller: useMessageScroller,
useMessageScrollerVisibility, and useMessageScrollerScrollable. They come
from the auto-installed @shadcn/react dependency, so there's nothing extra to
install. Reach for them only when composition can't express what you need.