Chat
A chat scroll container that anchors new turns near the top, follows streaming replies, and keeps the reader where they were when history loads above them.
Playground
MessageScroller is size-full and has nothing of its own to size against.import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@workspace/ui/components/message-scroller"
<div className="h-96">
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
<Message>{/* … */}</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</div>MessageScrollerProvider is headless — it owns the scroll state and renders nothing, so the hooks work anywhere below it, including in a toolbar that sits outside the frame. Everything else is a real element: MessageScroller is the frame, MessageScrollerViewport is the element that actually scrolls, and every direct child of MessageScrollerContent must be a MessageScrollerItem so it can be measured and anchored.MessageScrollerProvider
└── MessageScroller
├── MessageScrollerViewport
│ └── MessageScrollerContent
│ ├── MessageScrollerItem
│ └── MessageScrollerItem
└── MessageScrollerButtonscrollAnchor marks the row that should settle near the top when a new turn begins — usually the user message, but any row that starts a meaningful exchange works. scrollPreviousItemPeek (64px by default) leaves a sliver of the previous turn visible above it, so the new turn arrives with context instead of on a blank screen.<MessageScrollerProvider scrollPreviousItemPeek={64}>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
<Message>{/* … */}</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>
</MessageScrollerProvider>autoScroll follows content arriving at the live edge, but only while the reader is already there. Scrolling away, selecting text, or pressing a navigation key disengages it; the button re-engages it. Pass aria-busy to the content while a reply streams so screen readers batch the announcement instead of reading every token.<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent aria-busy={isStreaming}>
{/* transcript */}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>defaultScrollPosition decides where a transcript opens, and it applies once per mount — changing it later does nothing until the provider remounts, which is why the example below is keyed. While the position is being applied the viewport carries data-pending-scroll, so you can hide it and avoid a flash of the wrong position.<MessageScrollerProvider defaultScrollPosition="last-anchor">
{/* transcript */}
</MessageScrollerProvider>| Value | Description |
|---|---|
| "end" | The default. Opens at the absolute bottom — right for a live thread the reader is returning to mid-conversation. |
| "last-anchor" | Opens at the last anchored turn instead of the last pixel, so a reopened thread starts at the beginning of the final exchange rather than at the end of a long reply. |
| "start" | Opens at the top. Use it when the transcript is the document — a shared conversation, an archived thread, a transcript being reviewed. |
preserveScrollOnPrepend — on the viewport, and on by default — pins them to the row they were reading instead. It relies on stable messageId values: regenerate the ids on every fetch and there is nothing left to pin to. Turn the switch off to see the jump it prevents.<MessageScrollerViewport preserveScrollOnPrepend>
<MessageScrollerContent>
{[...olderMessages, ...messages].map((message) => (
<MessageScrollerItem key={message.id} messageId={message.id}>
<Message>{/* … */}</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>useMessageScroller drives the transcript from anywhere inside the provider — a search result, a citation, a table of contents. scrollToMessage returns true when the scroll ran or was queued for a row that has not mounted yet, and false when the target does not exist at all.const { scrollToMessage, scrollToStart, scrollToEnd } = useMessageScroller()
scrollToMessage(messageId, {
align: "start",
behavior: "smooth",
scrollMargin: 16,
})
scrollToStart({ behavior: "smooth" })
scrollToEnd({ behavior: "smooth" })useMessageScrollerVisibility reports the anchored turn and the ids currently on screen, in document order — enough to highlight the active turn in a sidebar or to mark a thread as read. The observer only runs while something subscribes, so the hook costs nothing on pages that do not call it.const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()useMessageScrollerScrollable reports whether there is more transcript in each direction. The same state mirrors to data-scrollable on the root and the viewport, and it is what makes MessageScrollerButton fade out and go inert at an edge. Shorten the transcript below and both values fall to false.const { start, end } = useMessageScrollerScrollable()MessageScrollerItem is a row boundary, not a message. Date separators, unread markers, typing indicators, and system notices all go in one — that is what keeps them measurable and stops them from breaking anchoring. Leave scrollAnchor off for rows that do not start a turn.<MessageScrollerItem messageId="marker-today">
<Marker variant="separator">
<MarkerContent>Today</MarkerContent>
</Marker>
</MessageScrollerItem>
<MessageScrollerItem messageId={message.id} scrollAnchor>
<Message align="end">{/* … */}</Message>
</MessageScrollerItem>prefers-reduced-motion.const MotionMessageScrollerItem = motion.create(MessageScrollerItem)
<MotionMessageScrollerItem
messageId={message.id}
scrollAnchor={message.role === "user"}
initial={{ opacity: 0, y: 16, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<Message>{/* … */}</Message>
</MotionMessageScrollerItem>content-visibility: auto and contain-intrinsic-size, which keeps hundreds to low thousands of turns comfortable. There is no built-in virtualisation; past that size, use MessageScrollerViewport as the scroll element for @tanstack/react-virtual and let the virtualiser own the rows.| Attribute | Description |
|---|---|
| data-pending-scroll | On the viewport while the opening position is being applied. Hide the content against it to avoid a flash. |
| data-autoscrolling | On the root and the viewport during a programmatic scroll. The viewport uses it to hide the scrollbar mid-jump. |
| data-scrollable | On the root and the viewport, with the values start and end for the directions that still have transcript left. |
| data-active | On the button. At false it fades out, stops taking pointer events, and is removed from the tab order. |
| data-scroll-anchor | On every item, mirroring scrollAnchor. |
role="region" labelled "Messages", so a keyboard can reach the transcript and page through it — override aria-label when the page holds more than one thread. The content is a role="log" with aria-relevant="additions", so new rows are announced and edits to old ones are not. The button is a real button that goes inert at the edge rather than staying focusable and doing nothing.<MessageScrollerViewport aria-label="Support thread">
<MessageScrollerContent aria-busy={isStreaming}>
{/* transcript */}
</MessageScrollerContent>
</MessageScrollerViewport>div — except the button, which renders a Button through render — and passes its native props through.| Part | Prop | Default |
|---|---|---|
| MessageScrollerProvider | autoScroll?: boolean | false |
| MessageScrollerProvider | defaultScrollPosition?: "start" | "end" | "last-anchor" | "end" |
| MessageScrollerProvider | scrollPreviousItemPeek?: number | 64 |
| MessageScrollerProvider | scrollEdgeThreshold?: number | 8 |
| MessageScrollerProvider | scrollMargin?: number | 0 |
| MessageScroller | className?: string | — |
| MessageScrollerViewport | preserveScrollOnPrepend?: boolean | true |
| MessageScrollerViewport | aria-label?: string | "Messages" |
| MessageScrollerContent | spacerClassName?: string | — |
| MessageScrollerItem | messageId?: string | — |
| MessageScrollerItem | scrollAnchor?: boolean | false |
| MessageScrollerButton | direction?: "start" | "end" | "end" |
| MessageScrollerButton | behavior?: ScrollBehavior | "smooth" |
| MessageScrollerButton | variant, size (forwarded to Button) | "secondary", "icon-sm" |
| Hook | Returns |
|---|---|
| useMessageScroller | { scrollToMessage, scrollToStart, scrollToEnd } — each returns a boolean |
| useMessageScrollerVisibility | { currentAnchorId: string | null, visibleMessageIds: string[] } |
| useMessageScrollerScrollable | { start: boolean, end: boolean } |
Tokens used
7 design tokens consumed by the Message Scroller component.
--background
Background
--foreground
Foreground
--muted
Muted
--border
Border
--ring
Ring
--radius-md
Radius Medium
--spacing
Spacing unit (4px)