Message Scroller
Follow streamed replies and revisit earlier messages with a preview navigation rail.
Overview
Message Scroller keeps new output in view while the reader stays near the end of a conversation. Scrolling back pauses following, so growing replies do not pull the reader away from an earlier message.
Set navigation="rail" to add message ticks on the right. Hover or focus a tick to preview its message, then activate it to scroll there. The rail appears only when the transcript overflows and contains at least two marked messages.
Use this component when you need control over the transcript's layout and navigation. Chat Thread includes a footer slot for the composer; Message Scroller leaves the composer layout to your application. Both can render Chat Message rows.
Behavior
Following output
Following starts enabled. The default live-edge threshold is 56 pixels from the end. Scrolling beyond that threshold pauses following; returning near the end resumes it. Selecting the last rail tick also resumes following. Set followOutput={false} to disable automatic following entirely.
onFollowChange reports when the reader leaves or returns to the live edge. It does not load messages or control your model connection.
Message markers
The rail finds descendants with data-slot="message". Add data-from="user" or data-from="assistant" to name each row in navigation labels and pair user-message previews with the following assistant reply.
Wrap just the message text in data-slot="message-content" or data-slot="message-bubble-content". This keeps author labels and action text out of previews. Without that wrapper, the component uses the whole row's text content.
Message additions, streamed text, and size changes refresh the rail. Give the scroller a bounded height, such as h-96, or place it in a flex layout with a constrained height and min-h-0.
Your application's responsibilities
Pass rendered messages as children, keep message keys stable, and supply loading and error content. busy marks the transcript as receiving updates; it does not draw a loading indicator. Put your composer outside the scroller so it stays available while the transcript scrolls.
Accessibility
The transcript is a keyboard-focusable section containing a polite live log. Set label to name the conversation and navigationLabel to name the rail. Set busy while a streamed response is incomplete. Reduced-motion preferences disable smooth scrolling and the rail's animated transitions.
Rail ticks use native buttons with names that identify the sender and message position. Keyboard focus shows the same preview as hover. A touch selects a message and pins its preview; a tap outside clears that pinned preview.
Installation
Usage
import { MessageScroller } from "@/components/ai/message-scroller"
<MessageScroller
label="Conversation"
navigation="rail"
busy={isStreaming}
className="h-96"
contentClassName="flex flex-col gap-5 p-5"
>
{messages.map((message) => (
<article
key={message.id}
data-slot="message"
data-from={message.role}
aria-label={message.role === "user" ? "You" : "Assistant"}
>
<div data-slot="message-content">{message.text}</div>
</article>
))}
</MessageScroller>The Generative UI playground uses this scroller with its composer fixed below the transcript.
API reference
MessageScroller
Accepts native div props, including children, in addition to those listed.
PreviewRail
Exported from the same file for standalone section navigation. items is required; each item has id and label, with optional ariaLabel, description, href, target, and rel.
For standalone items with href, the first touch previews the link and the second follows it. Mouse and keyboard activation follow the link directly. Custom preview content is visually displayed with aria-hidden="true"; keep essential information in the item label or linked destination.