Message Scroller

A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.

How much did I feed into the grid today?
You fed 8.4 kWh back into the grid so far today, about 62% of your solar production.
And what did I consume from it?
You drew 3.1 kWh from the grid, mostly between 6 and 8 in the morning before the sun came up.
Can I cover tonight from the battery?
Your battery is at 84%. That comfortably covers your typical evening load down to about 20% by morning.

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 Spark Button via render.

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

  • MessageScrollerViewport is a labelled, keyboard-focusable scroll region (role="region", aria-label="Messages", tabIndex={0}).
  • MessageScrollerContent marks the transcript as a live region (role="log", aria-relevant="additions"). Pass aria-busy while a turn streams if announcements should wait for the completed row.
  • MessageScrollerButton renders a real button; when there is nothing to scroll toward it sets inert, tabIndex={-1}, and data-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

PropTypeDefaultDescription
autoScrollbooleanfalseFollow streamed output while at the live edge.
defaultScrollPosition'start' | 'end' | 'last-anchor''end'Where the transcript opens.
scrollPreviousItemPeeknumberPixels of the previous item kept visible above an anchor.
scrollEdgeThresholdnumberDistance from an edge still treated as "at the edge".
scrollMarginnumberMargin applied when scrolling a row into view.

MessageScroller / MessageScrollerViewport / MessageScrollerContent

PropTypeDefaultDescription
classNamestringMerges with the base styles. MessageScrollerViewport also accepts preserveScrollOnPrepend (true).

MessageScrollerItem

PropTypeDefaultDescription
messageIdstringStable id used for jump-to, position preserve, tracking.
scrollAnchorbooleanfalseTreat this row as the start of a turn.
classNamestringMerges with the base row styles.

MessageScrollerButton

PropTypeDefaultDescription
direction'start' | 'end''end'Which edge the control scrolls toward.
styleVariantButton["styleVariant"]'surface'The underlying Spark Button styleVariant.
colorButton["color"]'neutral'The underlying Spark Button color.
sizeButton["size"]'icon-small'The underlying Spark Button size.
renderReactElementRender a custom control instead of the default Button.

Hooks

  • useMessageScroller(){ scrollToEnd, scrollToStart, scrollToMessage }
  • useMessageScrollerVisibility(){ currentAnchorId, visibleMessageIds }
  • useMessageScrollerScrollable(){ start, end }

On this page