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
<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.
<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.jsonUsage
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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
| autoScroll | boolean | – | 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. |
| scrollEdgeThreshold | number | – | Distance in pixels from the edge within which the viewport still counts as at the live edge. |
| scrollPreviousItemPeek | number | – | Pixels of the previous item left visible above an anchored row after an anchor scroll. |
| scrollMargin | number | – | Extra margin applied to programmatic scrolls. |
MessageScrollerViewport
| Prop | Type | Default | Description |
|---|---|---|---|
| preserveScrollOnPrepend | boolean | – | Keep the reading position stable when older messages are prepended (infinite history). |
MessageScrollerContent
| Prop | Type | Default | Description |
|---|---|---|---|
| spacerClassName | string | – | Classes for the internal spacer that pads short conversations to the full viewport height. |
MessageScrollerItem
| Prop | Type | Default | Description |
|---|---|---|---|
| messageId | string | – | Stable id for the row – used by scrollToMessage and the visibility hook. |
| scrollAnchor | boolean | false | Marks the row as a conversation turn that parks near the top of the viewport when scrolled to. |
MessageScrollerButton
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| behavior | ScrollBehavior | – | Scroll behavior for the jump, e.g. smooth. |
| variant / size | Button variant and size | "secondary" / "icon-sm" | Forwarded to the underlying Button used as the default render. |
| render | ReactElement | (props, state) => ReactElement | – | Replace the default Button element entirely. |
| Component | Description |
|---|---|
| MessageScrollerProvider | Context provider owning scroll state and behavior – hooks and every other part must live inside it. |
| MessageScroller | Relative, overflow-hidden root that positions the floating button (data-slot="message-scroller"). |
| MessageScrollerViewport | The 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. |
| MessageScrollerContent | Column of rows with gap-6 (data-slot="message-scroller-content"). |
| MessageScrollerItem | Wrapper for one row (data-slot="message-scroller-item"). Uses content-visibility so far off-screen rows skip layout work while staying in the DOM. |
| MessageScrollerButton | Floating 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.