Message Scroller
A virtualised conversation viewport that pins to the latest message, preserves prepend position, and provides a jump-to-latest control.
- Status
- Since
0.1.0- Accessibility pattern
- focusable scrollable region
Last updated
Install
Add Message Scroller from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/message-scrollerThe same command installs the registry items it composes: @vegastack/button, @vegastack/icon-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 className="h-96">
<MessageScrollerViewport aria-label="Conversation">
<MessageScrollerContent>
{messages.map((m) => (
<MessageScrollerItem key={m.id} messageId={m.id}>
{/* 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>;Give MessageScroller (or its parent) a bounded height so the viewport can
overflow and scroll.
Anatomy
MessageScrollerProvider— holds scroll state:autoScroll,defaultScrollPosition("start" | "end" | "last-anchor"),scrollPreviousItemPeek,scrollMargin.MessageScroller— the root flex column.MessageScrollerViewport— the scrollable region. Fades its bottom edge withscroll-fadeand hides the scrollbar during programmatic auto-scroll.MessageScrollerContent— the inner column; setaria-busywhile streaming.MessageScrollerItem— one item.content-visibilityskips off-screen items; setscrollAnchoron the item that should stay in view andmessageIdto target it.MessageScrollerButton— the floating jump-to-end (or start) button; renders ourButtonand animates in only when scrolled away. Place it as a direct child ofMessageScroller, alongside the viewport (not inside it) so it pins to the frame and floats over the bottom edge.
Hooks
useMessageScroller()→scrollToEnd(),scrollToStart(),scrollToMessage(id).useMessageScrollerScrollable()→{ start, end }(whether each edge can scroll).useMessageScrollerVisibility()→{ currentAnchorId, visibleMessageIds }.
Examples
Auto-scrolling thread
Sends pin to the bottom; scroll up and the jump-to-latest button slides in.
Commands
Drive the transcript from outside with useMessageScroller().scrollToMessage(id)
— here a "Jump to…" menu.
Scrollable state + scroll-to-start
useMessageScrollerScrollable() reports which edges can scroll. Render a second
MessageScrollerButton direction="start" to jump back to the top.
Can scroll up: false · down: false
Streaming
Set aria-busy on MessageScrollerContent while a response streams; autoScroll
follows the live edge. Pair a shimmer typing bubble for the in-progress cue.
Visibility outline
useMessageScrollerVisibility().currentAnchorId tracks the in-view turn — drive a
mini-map / outline that highlights and jumps to the active message.
Restore the anchor on open
Set defaultScrollPosition="last-anchor" on the provider to open at the last item
flagged with scrollAnchor instead of the top or bottom — for resuming a thread
where the reader left off. scrollPreviousItemPeek leaves a sliver of the message
above the anchor visible so the position reads as continuous.
Opens at the anchored turn (tinted) rather than the top or bottom, with a peek of the previous message above it.
A restyled jump button + live visibility count
MessageScrollerButton forwards variant and size from
Button — it defaults to outline, which already is a
page-coloured face with the one hairline and the surface-ladder hover — and accepts
custom children in place of the default arrow. It docks in and out with the shared
motion-dock-in / motion-dock-out pair: 150ms in, 100ms out, translate and fade. useMessageScrollerVisibility() reports every message currently in
the viewport via its visibleMessageIds array.
Visible now: 0 of 16 messages
API Reference
MessageScrollerProvider
MessageScrollerProviderPropsaliasesReact.ComponentProps<typeof Primitive.Provider>, a re-exported primitive type the docs type extractor can't introspect — so the rows below are hand-maintained and kept in sync with the primitive.
| Prop | Type | Default | Description |
|---|---|---|---|
autoScroll | boolean | false | Pin the viewport to the latest message and follow new content as it appends. |
defaultScrollPosition | "start" | "end" | "last-anchor" | "end" | Where the viewport rests on mount: the top, the bottom, or the last item flagged with scrollAnchor. |
scrollEdgeThreshold | number | 8 | Distance in px from an edge at which it counts as 'at the edge' for auto-scroll/scrollable state. |
scrollPreviousItemPeek | number | 64 | Pixels of the item above the anchor to keep visible when restoring to last-anchor. |
scrollMargin | number | 0 | Default margin (px) applied when scrolling an item into view via the hooks. |
children | React.ReactNode | — | The MessageScroller tree that reads this scroll state. |
MessageScrollerItem
MessageScrollerItemPropsaliasesReact.ComponentProps<typeof Primitive.Item>. It accepts all standard<div>props plus the two fields below.
| Prop | Type | Default | Description |
|---|---|---|---|
messageId | string | — | Stable id used to target this item from useMessageScroller().scrollToMessage(id). |
scrollAnchor | boolean | false | Mark this item as the one to keep in view on prepend, and (when last) the last-anchor target. |
MessageScrollerButton
| Prop | Type | Default | Description |
|---|---|---|---|
size | ButtonSize | 'md' | Control height, from the one xs · sm · md · lg vocabulary the --size-* tokens carry. |
tone | ButtonTone | — | |
variant | "cta" | "ghost" | "link" | "outline" | "soft" | "solid" | — |
Data attributes and CSS variables on MessageScrollerButton
| Attribute | Values |
|---|---|
data-direction | mirrors a prop or state value |
data-size | mirrors a prop or state value |
data-slot | "message-scroller-button" |
data-variant | mirrors a prop or state value |
MessageScroller, MessageScrollerViewport, and MessageScrollerContent accept
standard <div> props; the viewport also takes aria-label/aria-labelledby and
the content takes aria-busy.
Accessibility
- Give
MessageScrollerViewportanaria-label(oraria-labelledby); it is the focusable, scrollable region and uses the global:focus-visiblering. - The jump button has a visually-hidden label ("Scroll to end" / "Scroll to
start") and stays out of the tab order while inactive (
pointer-events-none). - Set
aria-busyonMessageScrollerContentwhile a response streams so assistive tech announces the in-progress state. - The slide/fade animations honour
prefers-reduced-motionvia the shared motion tokens.
| Contract | States tested |
|---|---|
| Behaviour | default, active, open |
| Accessibility | busy |
| Visual | default, hover, active |