Message Scroller

Message Scroller

A chat viewport that starts at the latest message, auto-scrolls while replies stream in, and pauses when the reader scrolls up.

Playground

Can you summarize the launch checklist?
AI
Sure – QA sign-off, changelog entry, and the status page update.
Who owns the status page update?
AI
Ops does. It goes out five minutes before the deploy starts.
Great – anything left after that?
AI
Just the post-launch metrics review on Friday. You're all set.
direction
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
  <MessageScroller>
    <MessageScrollerViewport>
      <MessageScrollerContent>
        {messages.map((message) => (
          <MessageScrollerItem key={message.id} messageId={message.id}>
            <ChatRow message={message} />
          </MessageScrollerItem>
        ))}
      </MessageScrollerContent>
    </MessageScrollerViewport>
    <MessageScrollerButton />
  </MessageScroller>
</MessageScrollerProvider>

Preview

Wrap the tree in MessageScrollerProvider, put the viewport, content and a floating MessageScrollerButton inside MessageScroller, and wrap each row in a MessageScrollerItem. Scroll up – the jump-to-latest button appears.

Can you summarize the launch checklist?
AI
Sure – QA sign-off, changelog entry, and the status page update.
Who owns the status page update?
AI
Ops does. It goes out five minutes before the deploy starts.
Great – anything left after that?
AI
Just the post-launch metrics review on Friday. You're all set.
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
  <MessageScroller>
    <MessageScrollerViewport className="px-4 py-4">
      <MessageScrollerContent>
        {messages.map((message) => (
          <MessageScrollerItem
            key={message.id}
            messageId={message.id}
            scrollAnchor={message.role === "user"}
          >
            <ChatRow message={message} />
          </MessageScrollerItem>
        ))}
      </MessageScrollerContent>
    </MessageScrollerViewport>
    <MessageScrollerButton />
  </MessageScroller>
</MessageScrollerProvider>

Installation

npx shadcn@latest add https://espresso-base-ui.vercel.app/r/message-scroller.json

Usage

import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
  useMessageScrollerScrollable,
  useMessageScrollerVisibility,
} from "@/components/ui/message-scroller"
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
  <MessageScroller>
    <MessageScrollerViewport>
      <MessageScrollerContent>
        <MessageScrollerItem messageId="m1">...</MessageScrollerItem>
        <MessageScrollerItem messageId="m2" scrollAnchor>
          ...
        </MessageScrollerItem>
      </MessageScrollerContent>
    </MessageScrollerViewport>
    <MessageScrollerButton />
  </MessageScroller>
</MessageScrollerProvider>

External controls

Components rendered anywhere inside the provider can drive the viewport with useMessageScroller – scrollToStart, scrollToEnd and scrollToMessage.

Can you summarize the launch checklist?
AI
Sure – QA sign-off, changelog entry, and the status page update.
Who owns the status page update?
AI
Ops does. It goes out five minutes before the deploy starts.
Great – anything left after that?
AI
Just the post-launch metrics review on Friday. You're all set.
function ScrollControls() {
  const { scrollToStart, scrollToEnd } = useMessageScroller()
  return (
    <div className="flex gap-2">
      <Button variant="outline" size="sm" onClick={() => scrollToStart()}>
        <ArrowUpToLineIcon /> Top
      </Button>
      <Button variant="outline" size="sm" onClick={() => scrollToEnd()}>
        <ArrowDownToLineIcon /> Latest
      </Button>
    </div>
  )
}

<MessageScrollerProvider defaultScrollPosition="start">
  <ScrollControls />
  <MessageScroller>...</MessageScroller>
</MessageScrollerProvider>

Anchored turns

Mark the rows that begin a conversation turn – typically the user's messages – with scrollAnchor. When that message is scrolled to, the viewport parks it near the top, with scrollPreviousItemPeek letting a slice of the previous row peek above it for context.

<MessageScrollerProvider
  autoScroll
  defaultScrollPosition="last-anchor"
  scrollPreviousItemPeek={64}
>
  ...
  <MessageScrollerItem messageId={message.id} scrollAnchor={message.role === "user"}>
    <ChatRow message={message} />
  </MessageScrollerItem>
  ...
</MessageScrollerProvider>

API reference

The wrapper re-exports the primitive's hooks: useMessageScroller returns scrollToStart, scrollToEnd and scrollToMessage(messageId, options); useMessageScrollerScrollable reports whether content extends beyond the start or end edge; and useMessageScrollerVisibility exposes currentAnchorId and visibleMessageIds. All hooks must be called under MessageScrollerProvider.

MessageScrollerProvider

PropTypeDefaultDescription
autoScrollboolean–Follow new content while the reader is at the live edge; scrolling away pauses it.
defaultScrollPosition"start" | "end" | "last-anchor"–Where the viewport starts on mount – the top, the latest message, or the last anchored item.
scrollEdgeThresholdnumber–Distance in pixels from the edge within which the viewport still counts as at the live edge.
scrollPreviousItemPeeknumber–Pixels of the previous item left visible above an anchored row after an anchor scroll.
scrollMarginnumber–Extra margin applied to programmatic scrolls.

MessageScrollerViewport

PropTypeDefaultDescription
preserveScrollOnPrependboolean–Keep the reading position stable when older messages are prepended (infinite history).

MessageScrollerContent

PropTypeDefaultDescription
spacerClassNamestring–Classes for the internal spacer that pads short conversations to the full viewport height.

MessageScrollerItem

PropTypeDefaultDescription
messageIdstring–Stable id for the row – used by scrollToMessage and the visibility hook.
scrollAnchorbooleanfalseMarks the row as a conversation turn that parks near the top of the viewport when scrolled to.

MessageScrollerButton

PropTypeDefaultDescription
direction"start" | "end""end"Which edge the button jumps to. It only appears while more content lies in that direction; the icon flips for start.
behaviorScrollBehavior–Scroll behavior for the jump, e.g. smooth.
variant / sizeButton variant and size"secondary" / "icon-sm"Forwarded to the underlying Button used as the default render.
renderReactElement | (props, state) => ReactElement–Replace the default Button element entirely.
ComponentDescription
MessageScrollerProviderContext provider owning scroll state and behavior – hooks and every other part must live inside it.
MessageScrollerRelative, overflow-hidden root that positions the floating button (data-slot="message-scroller").
MessageScrollerViewportThe scrollable element (data-slot="message-scroller-viewport") – thin scrollbar, bottom scroll fade, and a data-autoscrolling attribute that hides the scrollbar while auto-scroll drives it.
MessageScrollerContentColumn of rows with gap-6 (data-slot="message-scroller-content").
MessageScrollerItemWrapper for one row (data-slot="message-scroller-item"). Uses content-visibility so far off-screen rows skip layout work while staying in the DOM.
MessageScrollerButtonFloating jump button (data-slot="message-scroller-button") with data-direction, data-active, data-variant and data-size attributes driving its slide-and-fade transitions. Includes a screen-reader label.

Accessibility & styling hooks

The default button content includes an sr-only label ("Scroll to end" or "Scroll to start"), and off-screen rows remain in the DOM – content-visibility only skips rendering work, so find-in-page and assistive tech still see the full transcript. Each part exposes a data-slot attribute, the viewport reflects data-autoscrolling, and the button reflects data-active and data-direction – target these from CSS for app-level overrides.

Search documentation

Search the component docs