Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

Message Scroller

A virtualised conversation viewport that pins to the latest message, preserves prepend position, and provides a jump-to-latest control.

Status
stable
Since
0.1.0
Accessibility pattern
focusable scrollable region

Last updated

Hey — welcome to the thread!
This viewport auto-scrolls to the latest message.
Scroll up and the jump-to-latest button appears.
Older messages keep their position when more load.
It virtualises long histories with content-visibility.
Try the controls below.

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-scroller

The 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 with scroll-fade and hides the scrollbar during programmatic auto-scroll.
  • MessageScrollerContent — the inner column; set aria-busy while streaming.
  • MessageScrollerItem — one item. content-visibility skips off-screen items; set scrollAnchor on the item that should stay in view and messageId to target it.
  • MessageScrollerButton — the floating jump-to-end (or start) button; renders our Button and animates in only when scrolled away. Place it as a direct child of MessageScroller, alongside the viewport (not inside it) so it pins to the frame and floats over the bottom edge.
MessageScroller — data-slot="message-scroller"
MessageScrollerButton — data-slot="message-scroller-button"
MessageScrollerContent — data-slot="message-scroller-content"
MessageScrollerItem — data-slot="message-scroller-item"
MessageScrollerProvider
MessageScrollerViewport — data-slot="message-scroller-viewport"

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.

Hey — welcome to the thread!
This viewport auto-scrolls to the latest message.
Scroll up and the jump-to-latest button appears.
Older messages keep their position when more load.
It virtualises long histories with content-visibility.
Try the controls below.

Commands

Drive the transcript from outside with useMessageScroller().scrollToMessage(id) — here a "Jump to…" menu.

Message 1
Message 2
Message 3
Message 4
Message 5
Message 6
Message 7
Message 8
Message 9
Message 10
Message 11
Message 12

Scrollable state + scroll-to-start

useMessageScrollerScrollable() reports which edges can scroll. Render a second MessageScrollerButton direction="start" to jump back to the top.

Message 1
Message 2
Message 3
Message 4
Message 5
Message 6
Message 7
Message 8
Message 9
Message 10
Message 11
Message 12
Message 13
Message 14

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.

Hey — welcome to the thread!
This viewport auto-scrolls to the latest message.
Scroll up and the jump-to-latest button appears.
Older messages keep their position when more load.

Visibility outline

useMessageScrollerVisibility().currentAnchorId tracks the in-view turn — drive a mini-map / outline that highlights and jumps to the active message.

Turn 1 of the conversation
Turn 2 of the conversation
Turn 3 of the conversation
Turn 4 of the conversation
Turn 5 of the conversation
Turn 6 of the conversation
Turn 7 of the conversation
Turn 8 of the conversation

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.

Turn 1
Turn 2
Turn 3
Turn 4
Turn 5
Turn 6 — restored into view
Turn 7
Turn 8
Turn 9
Turn 10

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.

Message 1
Message 2
Message 3
Message 4
Message 5
Message 6
Message 7
Message 8
Message 9
Message 10
Message 11
Message 12
Message 13
Message 14
Message 15
Message 16

Visible now: 0 of 16 messages

API Reference

MessageScrollerProvider

MessageScrollerProviderProps aliases React.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.

PropTypeDefaultDescription
autoScrollbooleanfalsePin 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.
scrollEdgeThresholdnumber8Distance in px from an edge at which it counts as 'at the edge' for auto-scroll/scrollable state.
scrollPreviousItemPeeknumber64Pixels of the item above the anchor to keep visible when restoring to last-anchor.
scrollMarginnumber0Default margin (px) applied when scrolling an item into view via the hooks.
childrenReact.ReactNodeThe MessageScroller tree that reads this scroll state.

MessageScrollerItem

MessageScrollerItemProps aliases React.ComponentProps<typeof Primitive.Item>. It accepts all standard <div> props plus the two fields below.

PropTypeDefaultDescription
messageIdstringStable id used to target this item from useMessageScroller().scrollToMessage(id).
scrollAnchorbooleanfalseMark this item as the one to keep in view on prepend, and (when last) the last-anchor target.

MessageScrollerButton

PropTypeDefaultDescription
sizeButtonSize'md'Control height, from the one xs · sm · md · lg vocabulary the --size-* tokens carry.
toneButtonTone
variant"cta" | "ghost" | "link" | "outline" | "soft" | "solid"

Data attributes and CSS variables on MessageScrollerButton

AttributeValues
data-directionmirrors a prop or state value
data-sizemirrors a prop or state value
data-slot"message-scroller-button"
data-variantmirrors 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 MessageScrollerViewport an aria-label (or aria-labelledby); it is the focusable, scrollable region and uses the global :focus-visible ring.
  • 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-busy on MessageScrollerContent while a response streams so assistive tech announces the in-progress state.
  • The slide/fade animations honour prefers-reduced-motion via the shared motion tokens.
ContractStates tested
Behaviourdefault, active, open
Accessibilitybusy
Visualdefault, hover, active

Do / Don't

Do
Use a Message Scroller for live chat/AI threads that must auto-scroll, virtualise long histories, and offer a jump-to-latest control.
Don't
Use it for a short, static list that already fits — render Message rows in a plain container or a Scroll Area instead.

On this page