GitHubnpm

Accent theme

Base radius

GitHubnpm

Accent theme

Base radius

Colour scheme

Docs menu

Guides


Primitives
Forms
Layout
Feedback
Overlays
Navigation
Data Display
AI

Message Scroller

A conversation viewport that follows streamed output at the live edge, and releases control the moment the reader scrolls away. It's a native overflow container with role="log" andaria-live="polite" — the standard chat-transcript pattern — plus one behaviour that's tedious to get right by hand: knowing when "keep up with the stream" should stop.

  1. Assistant
    Pinned to the live edge — this transcript follows itself.
  2. You
    Scroll up while it streams and the viewport lets go immediately.

Playground

Toggle every adjustable prop and watch the component — and the code — update live.

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

Same component — Kernel look stripped. See Platforms.

jump to latest
Usage
<MessageScroller
  maxBlockSize="12rem"
  showJumpToLatest={true}
  jumpLabel="Jump to latest"
  threshold={24}
  onPinnedChange={(pinned) => console.log(pinned)}
>
  <MessageList>
    {messages.map((m) => (
      <Message key={m.id} author={m.author}>
        <MessageBubble>{m.text}</MessageBubble>
      </Message>
    ))}
  </MessageList>
</MessageScroller>

Props

Props for Message Scroller.
PropTypeDefault
maxBlockSizestring
defaultPinnedbooleantrue
thresholdnumber24
onPinnedChange(pinned: boolean) => void
showJumpToLatestbooleantrue
jumpLabelReactNode"Jump to latest"
conversationKeyunknown

Why pinning isn't a controlled prop

Pinning isn't application state. It's an answer to "is the reader currently at the bottom?", which only the DOM knows. A controlledpinned prop would let a parent assertpinned while the reader is 400px up the transcript, and the only way to honour it would be to yank them back down mid-read. SodefaultPinned seeds it, onPinnedChangereports it, and the jump control (or scrolling back to the bottom) re-pins it.

Pin state is derived from scroll position alone — no wheel, touch, or key heuristics. Every scroll the controller performs itself is flagged, so any unflagged scroll event is by definition the reader moving, and "did they end up at the bottom?" answers both the unpin and the re-pin case with one rule. Wheel and touch listeners would re-derive the same answer less reliably, and would miss keyboard and scrollbar-drag scrolling entirely.

Using the behaviour on its own

The core is exported for surfaces that scroll but aren't transcripts — a streaming log, a terminal pane, CodeBlock's own following. useStickToBottom() returns{ viewportRef, contentRef, pinned, scrollToBottom };StickToBottomController is the framework-free class both packages share.

Accessibility

  • role="log" with aria-live="polite" announces appended messages without interrupting. It's deliberately verbose for very long transcripts, so both attributes are overridable — passaria-live="off" when your app announces messages itself.
  • The viewport is focusable. An overflow container that isn't focusable can't be scrolled by keyboard at all in Firefox or Safari, which strands keyboard-only readers in a transcript they can see and can't move.
  • The jump control is hidden with visibility rather thandisplay, so it leaves the tab order while hidden and both its reveal and its dismissal can still transition.
  • Following the live edge is always instant, never smooth-scrolled: a per-chunk animation would never catch up with the next chunk. Only the explicit jump animates, and prefers-reduced-motion makes that instant too.