Message Scroller
A chat transcript scroller that follows the live edge only while the reader is, anchors each new turn near the top, and never pulls them away.
- Status
- Since
0.1.0- Accessibility pattern
- focusable scrollable region
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Message Scroller before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify message-scrollerInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/message-scrollerRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button, @vegastack/use-media-query.
It also adds the sanctioned engine to your package.json: @shadcn/react.
Usage
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller";
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
<MessageScroller>
<MessageScrollerViewport aria-label="Conversation">
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
{/* a Message row */}
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
{/* The button is a SIBLING of the viewport — a direct child of the
relative MessageScroller frame — so it floats over the bottom edge. */}
<MessageScrollerButton direction="end" />
</MessageScroller>
</MessageScrollerProvider>;MessageScroller fills its parent, so give it (or that parent) a bounded height and let the
viewport do the overflowing.
<div className="flex h-screen flex-col">
<MessageScrollerProvider>
<MessageScroller className="flex-1">{/* transcript */}</MessageScroller>
</MessageScrollerProvider>
</div>Anatomy
MessageScrollerProvider— the headless root. Owns scroll state and the behaviour props:autoScroll,defaultScrollPosition,scrollPreviousItemPeek,scrollMargin,scrollEdgeThreshold.MessageScroller— the styled frame. A relative, clipping flex column.MessageScrollerViewport— the scrollable element. Receives native scroll events, preserves the visible row when older messages are prepended, and fades its bottom edge withscroll-fade.MessageScrollerContent— the transcript container, and the live region for new rows.MessageScrollerItem— one row boundary. Wrap every direct child of the content so the scroller can measure, anchor, preserve position and jump to it. A row can be a message, a marker, a typing indicator or a "load earlier" control.MessageScrollerButton— the scroll control. Renders ourButtonand is inert until there is content in its direction.
Hooks
useMessageScroller()→scrollToEnd(),scrollToStart(),scrollToMessage(id, options).useMessageScrollerScrollable()→{ start, end }— whether each edge can scroll.useMessageScrollerVisibility()→{ currentAnchorId, visibleMessageIds }.
Examples
What Makes a Great Streaming Chat Experience
Streaming broke the old model. Messages arrive in chunks while the reader may still be reading, scrolling, or looking somewhere else, so the whole problem becomes scroll: when to follow, when to hold, and when to let the reader decide.
Move only when the reader asked to move; follow only while they are following; treat every interaction — scroll, selection, a keystroke, a link — as a signal to stop moving; start a new turn near the top so it can be read from the beginning; keep enough of the previous turn visible that the exchange stays connected; let the rest arrive offscreen; and make coming back to the live edge one obvious action. Never move the reader against their intent.
Scroll up, then receive a reply. With following off the transcript never moves under you — the jump control is how you come back.
MessageScroller
MessageScrollerProvider owns the scroll state and the transcript-row behaviour — opening position,
streamed output, new-turn anchoring, prepended history, visibility and the scroll commands.
MessageScroller is the styled frame that renders inside it.
It is scoped to the scroll viewport, and to nothing else: it does not own messages, AI state, transport, persistence, branching or model state. Your product code stays focused on composing messages, markers, tools, attachments and prompt inputs.
The provider owns scroll state; the frame owns layout. Neither owns the messages, the transport, or the model — those stay yours.
Composition
MessageScrollerProvider
└── MessageScroller
├── MessageScrollerViewport
│ └── MessageScrollerContent
│ ├── MessageScrollerItem
│ ├── MessageScrollerItem
│ └── MessageScrollerItem
└── MessageScrollerButtonThe button is a sibling of the viewport, not a child of it: it pins to the frame and floats over the edge it scrolls toward.
Dashed: the frame, the viewport it scrolls, and every row boundary. Wrap every direct child of the content in an item so the scroller can measure, anchor and jump to it.
Core Concepts
A turn is the part of the conversation that starts a new exchange — usually the user's message and
the reply that follows it. An anchor is the row the viewport should treat as the start of that turn;
mark it with scrollAnchor.
// This tells the scroller to anchor the user's message for the next turn.
<MessageScrollerItem
messageId={message.id}
scrollAnchor={message.role === "user"}
/>Anchors are not tied to a message role. Any row can be one — a user message, a system marker, a handoff event — and 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 never feels detached from its context.
The anchored row moves near the top of the viewport and the reply grows in below it, instead of the whole thread jumping to the bottom.
Group Chat
In a group chat the turn boundary is more specific than "the user message" — it is the message that asks for a response, or a marker like "Marcus joined the chat". Because anchoring is role-independent, a marker anchors exactly as easily as a message, and a typing indicator or a history control simply does not.
<MessageScrollerItem messageId="marcus-joined" scrollAnchor>
<Marker variant="separator">
<MarkerContent>Marcus joined the chat</MarkerContent>
</Marker>
</MessageScrollerItem>The join marker is the anchor, so the thread opens on the moment the room changed. The typing row is deliberately not one.
Keeping Context Visible
scrollPreviousItemPeek keeps a slice of the previous item visible above the anchor, so a new turn
still reads as part of the same continuous thread instead of restarting on a blank page.
// Keep 64px of the previous turn visible above the newly anchored row.
<MessageScrollerProvider scrollPreviousItemPeek={64}>
<MessageScroller>{/* anchored turns */}</MessageScroller>
</MessageScrollerProvider>At 0 the anchored turn sits flush against the top edge and reads like a fresh page. A peek keeps the previous turn in frame.
Following the Live Edge
With autoScroll, the viewport follows new content — but only while the reader is at the live edge.
Set aria-busy on the content while a turn streams so announcements wait for the completed row.
<MessageScrollerContent aria-busy={status === "streaming"}>
{/* messages */}
</MessageScrollerContent>Opening Saved Threads
A saved conversation should open at the last meaningful turn, which is usually the last user message
rather than the absolute bottom. defaultScrollPosition="last-anchor" restores the last row flagged
with scrollAnchor.
Opens at the anchored turn (tinted) rather than the top or bottom, with a peek of the previous message above it.
Avoiding a Flash on Reload
A scroll container always opens at the top and HTML has no way to set scrollTop, so a
server-rendered transcript would show the oldest messages and then jump once JavaScript applies
defaultScrollPosition. While "end" or "last-anchor" is still pending, the viewport carries
data-pending-scroll and the styled recipe keeps it invisible: you see the frame, never the jump.
"start" needs none of this.
If you want "end" visible on first paint, give the viewport an id and scroll it from an inline
script placed right after it in your page — never inside the scroller — with
suppressHydrationWarning on the viewport and a nonce if you run a Content Security Policy. It
only works for "end", and only when the messages are already in the HTML.
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>;A scroll container always opens at the top, so a server-rendered transcript would show the oldest row and then jump. While data-pending-scroll is on the viewport the styled recipe keeps it invisible: you see the frame, never the jump.
Loading Earlier Messages
Prepending older rows must not move the conversation the reader is already looking at. The viewport
preserves the visible row while history loads above it — preserveScrollOnPrepend, on by default.
Rows are prepended above the reader. The viewport preserves the visible row, so history loads without moving what is being read — that is preserveScrollOnPrepend, on by default.
Animating New Messages
MessageScrollerItem animates directly: keep messageId and scrollAnchor on it and use transform
and opacity for the entrance. Here the sent row wears motion-enter-up, the system's fade-and-rise
arrival, so it looks like it comes from the live edge; base.css collapses it under
prefers-reduced-motion.
Jumping to Messages
useMessageScroller().scrollToMessage(id, options) drives the transcript from outside — a jump menu,
a search result, an unread marker, a permalink.
Tracking the Reader's Position
useMessageScrollerVisibility() reports currentAnchorId and visibleMessageIds. It is
pay-for-what-you-use: nothing is tracked until something subscribes.
Reading Scroll State
useMessageScrollerScrollable() reports which edges can still scroll, which is what a second
MessageScrollerButton direction="start" — or your own affordance — reads.
Can scroll up: false · down: false
Performance
The goal is to keep the scroll hot path outside React state: no rerenders for transcript rows, no
forced layout on every scroll, and as little off-screen paint as the browser can avoid. Scroll
position, anchoring and follow-output are tracked imperatively and mirrored onto the root and
viewport as data-* attributes.
MessageScrollerItem ships with content-visibility: auto and contain-intrinsic-size, so rows
stay in the DOM for selection, copy, find-in-page, SSR and assistive tech while the browser skips
rendering work for rows far outside the viewport. That is comfortable for the expected range of a
chat transcript: hundreds to low thousands of turns, markdown and composed rows included.
Sixty real rows, no virtualiser. Scroll position and anchoring are tracked imperatively, and each item carries content-visibility: auto so the browser skips paint work for rows far outside the viewport.
Virtualization
Virtualisation is deliberately outside the primitive — real DOM rows stay fast well into the
thousands of turns, so most transcripts never need it. When one does, use MessageScrollerViewport
as the virtualiser's scroll element and let the virtualiser own the rows.
A thousand rows. The virtualiser owns the rows; the viewport is only its scroll element, so the jump control and the scroll state still come from the provider.
Unstyled
The behaviour comes from @shadcn/react/message-scroller. Mount those parts directly when the
transcript has to look like something else entirely; the styled components above are that engine plus
our token chrome and nothing more.
The same behaviour with none of our chrome: every class here is the example's own. Reach for this when the transcript has to look like something else entirely.
Jump Button Variants
MessageScrollerButton forwards variant and size to Button and
accepts custom children in place of the default arrow. It defaults to secondary / icon-sm.
Visible now: 0 of 16 messages
API Reference
MessageScrollerProvider
The provider is a pass-through to the
@shadcn/reactprimitive, whose props the docs type extractor cannot introspect — so the rows below are hand-maintained against the engine.
| Prop | Type | Default | Description |
|---|---|---|---|
autoScroll | boolean | false | Follow new content while the reader is at the live edge. Never pulls a reader who has scrolled away. |
defaultScrollPosition | "start" | "end" | "last-anchor" | "end" | Where the viewport rests on mount: the top, the bottom, or the last row flagged with scrollAnchor. |
scrollEdgeThreshold | number | 8 | Distance in px from an edge at which it counts as 'at the edge' for auto-scroll and scrollable state. |
scrollPreviousItemPeek | number | 64 | Pixels of the row above the anchor kept visible when a turn is anchored or last-anchor is restored. |
scrollMargin | number | 0 | Default margin in px applied when a row is scrolled into view through the hooks. |
children | React.ReactNode | — | The MessageScroller tree that reads this scroll state. |
MessageScrollerViewport
| Prop | Type | Default | Description |
|---|---|---|---|
preserveScrollOnPrepend | boolean | true | Keep the visible row in place when older messages are prepended above the transcript. |
aria-label | string | "Messages" | The scroll region's accessible name. The engine names it Messages unless you say otherwise. |
tabIndex | number | 0 | The viewport is a keyboard-focusable scroll region. See Deviations for the flagged unconditional default. |
MessageScrollerItem
| Prop | Type | Default | Description |
|---|---|---|---|
messageId | string | — | Stable id used to target this row from useMessageScroller().scrollToMessage(id). |
scrollAnchor | boolean | false | Mark this row as a turn boundary: the anchor to move near the top, and the last-anchor restore target. |
MessageScrollerButton
| Prop | Type | Default | Description |
|---|---|---|---|
direction | "start" | "end" | "end" | Which edge this control scrolls toward. It is inert while there is nothing in that direction. |
behavior | "auto" | "smooth" | "smooth" | Scroll behaviour for the click. Forced to auto under prefers-reduced-motion (MOT-5), whatever you pass. |
variant | "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" | "secondary" | Forwarded to the Button this control renders. |
size | "xs" | "sm" | "default" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | "icon-sm" | Forwarded to the Button this control renders. |
MessageScroller and MessageScrollerContent add no props of their own — each accepts everything
the underlying <div> accepts, and the content additionally takes aria-busy and aria-relevant.
Accessibility
MessageScrollerViewportis a labelled, keyboard-focusable scroll region: the engine gives itrole="region",aria-label="Messages"andtabIndex={0}, so a keyboard user can focus the transcript and scroll it directly. Pass your ownaria-labelwhen the page has more than one.MessageScrollerContentis the live region —role="log"witharia-relevant="additions"— so new rows can be announced without every streamed token being read out. Setaria-busywhile a turn streams and the announcement waits for the completed row (A11Y-3). The component adds no second live region of its own (A11Y-4): onelogis the whole announcement surface.MessageScrollerButtonis a real button with a visually hidden label ("Scroll to end" / "Scroll to start"). With nothing to scroll toward it setsinert,tabIndex={-1}anddata-active="false", so an inactive control is never an extra focus stop.- Focus on the viewport is the global 2px
:focus-visibleoutline, pulled inside the frame's clip withfocus-visible:-outline-offset-2(FOC-9) so it is not cut away byoverflow-hidden. - The jump control's click scroll is forced to
behavior: "auto"underprefers-reduced-motion(MOT-5), and the dock-in/dock-out transition collapses with the global reduced-motion reset.
Tab into the transcript: the viewport is a labelled role="region" and a tab stop, so a keyboard user can scroll it directly. The content is role="log" with aria-relevant="additions", and aria-busy holds announcements until the turn finishes.
| Contract | States tested |
|---|---|
| Behaviour | default, active, open |
| Accessibility | busy |
| Visual | default, hover, active |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/message-scroller.patch. Every hunk:
- FOC-9 —
MessageScrollerViewportgainsfocus-visible:-outline-offset-2. The viewport is a tab stop andMessageScrollerabove it clips withoverflow-hidden, sobase.css's outwardoutline-offset: 1pxis drawn and then cut away by the ancestor. This is the one permitted local focus deviation; nothing but the offset moves. - MOT-5 —
MessageScrollerButtonreadsusePrefersReducedMotion()and forces the engine's scrollbehaviorto"auto". The engine defaults it to"smooth"and knows nothing about the preference, and the global reset inbase.csscannot reach it: ascrollTothat asks forsmoothexplicitly outranks thescroll-behaviorproperty. This wrapper is the only place the preference can be honoured, so it overrides whatever the consumer passed. - A11Y-3, A11Y-4 — NO HUNK. Measured in
@shadcn/react@0.3.1:Contentalready rendersrole="log"witharia-relevant="additions", andViewportrendersrole="region"witharia-label="Messages". That is A11Y-3 verbatim — a polite region announcing added rows rather than every streamed token, and noalertanywhere — and A11Y-4's one-region rule is satisfied by that singlelog; auseAnnouncerbeside it would announce every message twice.message-scroller.test.tsxasserts both roles and thearia-relevantvalue, so the day the engine drops them the claim fails instead of going quiet. - A11Y-2 — audited, NO HUNK. The one control is
MessageScrollerButton, aButtonatsize="icon-sm"—size-7, 28px, over the floor. - NOT PATCHED, FLAGGED FOR MK — the engine writes
tabIndex: value ?? 0on the viewport unconditionally, so a transcript short enough not to scroll is still a tab stop, which is the half of A11Y-6 that Base UI'sScrollArea.Viewportgets right. A11Y-6 is not assigned to this component, its second clause licenses a namedrole="region"viewport, and the pre-reset component did not condition it either, so nothing is invented here. The test pins the engine's exact shape, so this note fails as stale the day upstream conditions it. - DOC-1, DOC-2 —
cnis imported from@vegastack/design, and the rest of the diff is prettier's reflow plus theregistry-stamp.mjsprovenance header the three-copy model needs.