Message Scroller
A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.
MessageScroller is a chat transcript scroller. MessageScrollerProvider owns
the scroll state and transcript-row behavior (opening position, streamed
output, new-turn anchoring, prepended history, visibility, and scroll controls),
and MessageScroller is the styled frame that renders inside it.
It is scoped to the scroll viewport. It does not own messages, AI state,
transport, persistence, or model state, so your product code stays focused on
composing messages, markers, attachments, and prompt inputs. The behavior comes
from the headless
@shadcn/react
message-scroller primitive; this component is the Spark-styled wrapper.
Installation
pnpm add @epilot/spark-ui @shadcn/react@shadcn/react ships the headless primitive MessageScroller builds on.
Usage
import { Message } from '@epilot/spark-ui/message'
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from '@epilot/spark-ui/message-scroller'<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === 'user'}
>
<Message />
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>MessageScroller fills its parent, so place it inside a height-constrained
container.
<div className="flex h-screen flex-col">
<MessageScrollerProvider>
<MessageScroller className="flex-1">{/* transcript */}</MessageScroller>
</MessageScrollerProvider>
</div>Composition
MessageScrollerProvider: the headless root. Owns scroll state and the behavior props for opening position, auto-scroll, anchoring, and visibility tracking.MessageScroller: the styled frame. Lays out the viewport, content, and controls inside the provider.MessageScrollerViewport: the scrollable element. Receives native scroll events and preserves the visible row when older messages are prepended.MessageScrollerContent: the transcript container. Holds the rows and provides the live-region defaults for new messages.MessageScrollerItem: the transcript row boundary. Wrap every direct child of the content so the scroller can measure, anchor, preserve position, track visibility, and jump to it.MessageScrollerButton: the scroll control. Scrolls to the start or end of the transcript and is inert until there is content in its direction. It renders a SparkButtonviarender.
Core concepts
Anchoring turns
A turn is the part of the conversation that starts a new exchange, usually the
user's message and the assistant reply that follows. Mark the row that should
start a turn with scrollAnchor. When a new anchor is appended, the viewport
moves it near the top and keeps a peek of the previous item above it, so the new
turn does not feel detached from its context.
// Anchor the user's message for the next turn.
<MessageScrollerItem
messageId={message.id}
scrollAnchor={message.role === 'user'}
/>Anchors are not tied to message role. Any row can be an anchor: a user message, a system marker, a handoff event.
Following the live edge
When the reader is at the live edge, autoScroll keeps streamed replies in view
as they grow. Scrolling away (by wheel, touch, keyboard, or dragging the
scrollbar) releases the view, so new chunks can arrive without moving the reader. The
root and viewport expose data-autoscrolling while a programmatic scroll to the
latest message runs.
<MessageScrollerProvider autoScroll>
<MessageScroller>{/* streamed turns */}</MessageScroller>
</MessageScrollerProvider>Opening saved threads
Reopening at the absolute end often drops the reader in without context. A
better default is "last-anchor": show the last meaningful turn, like the
user's latest message, with the reply below it. Use "start" or "end" when
those are the right place to land.
<MessageScrollerProvider defaultScrollPosition="last-anchor">
<MessageScroller>{/* transcript */}</MessageScroller>
</MessageScrollerProvider>Keeping context visible
scrollPreviousItemPeek keeps a slice of the previous item visible above the
anchor, so a new turn still feels part of the same thread.
// Keep 64px of the previous turn visible above the newly anchored row.
<MessageScrollerProvider scrollPreviousItemPeek={64}>
<MessageScroller>{/* anchored turns */}</MessageScroller>
</MessageScrollerProvider>Loading earlier messages
When older rows are prepended above the current transcript,
MessageScrollerViewport preserves the visible row so the reader stays put.
This is on by default via preserveScrollOnPrepend. Use stable messageId
values so the scroller has a specific row to preserve.
Driving the transcript from outside
Search results, permalinks, and toolbar buttons often drive the transcript from
outside the message list. The hooks read from MessageScrollerProvider, so they
work in any component inside it.
import { useMessageScroller } from '@epilot/spark-ui/message-scroller'
const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller()scrollToMessage targets the messageId on MessageScrollerItem and returns
false when the target is not mounted and cannot be queued.
Use useMessageScrollerVisibility for "where am I" state (currentAnchorId,
visibleMessageIds) and useMessageScrollerScrollable for edge state
(start, end). Both are pay-for-what-you-use: tracking only runs while
something subscribes.
Performance
MessageScrollerItem ships with content-visibility: auto and
contain-intrinsic-size, so rows stay in the DOM for selection, find-in-page,
and assistive tech while the browser skips rendering work for rows far outside
the viewport. Scroll position, anchoring, and follow-output are tracked
imperatively and mirrored onto the root and viewport through data-*
attributes, so scrolling and streaming do not rerender transcript rows. This is
comfortable into the low thousands of turns; when you need more, use
MessageScrollerViewport as the scroll element and let a virtualizer own the
rows.
Accessibility
MessageScrollerViewportis a labelled, keyboard-focusable scroll region (role="region",aria-label="Messages",tabIndex={0}).MessageScrollerContentmarks the transcript as a live region (role="log",aria-relevant="additions"). Passaria-busywhile a turn streams if announcements should wait for the completed row.MessageScrollerButtonrenders a real button; when there is nothing to scroll toward it setsinert,tabIndex={-1}, anddata-active="false"so it creates no extra focus stops.
API Reference
Built on the @shadcn/react Message Scroller primitive. All primitive props pass through on every export.
MessageScrollerProvider
| Prop | Type | Default | Description |
|---|---|---|---|
autoScroll | boolean | false | Follow streamed output while at the live edge. |
defaultScrollPosition | 'start' | 'end' | 'last-anchor' | 'end' | Where the transcript opens. |
scrollPreviousItemPeek | number | — | Pixels of the previous item kept visible above an anchor. |
scrollEdgeThreshold | number | — | Distance from an edge still treated as "at the edge". |
scrollMargin | number | — | Margin applied when scrolling a row into view. |
MessageScroller / MessageScrollerViewport / MessageScrollerContent
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merges with the base styles. MessageScrollerViewport also accepts preserveScrollOnPrepend (true). |
MessageScrollerItem
| Prop | Type | Default | Description |
|---|---|---|---|
messageId | string | — | Stable id used for jump-to, position preserve, tracking. |
scrollAnchor | boolean | false | Treat this row as the start of a turn. |
className | string | — | Merges with the base row styles. |
MessageScrollerButton
| Prop | Type | Default | Description |
|---|---|---|---|
direction | 'start' | 'end' | 'end' | Which edge the control scrolls toward. |
styleVariant | Button["styleVariant"] | 'surface' | The underlying Spark Button styleVariant. |
color | Button["color"] | 'neutral' | The underlying Spark Button color. |
size | Button["size"] | 'icon-small' | The underlying Spark Button size. |
render | ReactElement | — | Render a custom control instead of the default Button. |
Hooks
useMessageScroller()→{ scrollToEnd, scrollToStart, scrollToMessage }useMessageScrollerVisibility()→{ currentAnchorId, visibleMessageIds }useMessageScrollerScrollable()→{ start, end }