Message Scroller
Scroll a chat thread that opens at the latest message, follows a streaming reply and leads back to the latest message.
Northlight AI can make mistakes. Check important information.
Installation
pnpm dlx shadcn@4.18.0 add @siteplane/message-scrollerComplete the one-time setup first. Registry JSON lists source files and dependencies.
For this page’s examples, also install:
pnpm dlx shadcn@4.18.0 add @siteplane/bubble @siteplane/copy-button @siteplane/marker @siteplane/menu @siteplane/message @siteplane/prompt-input @siteplane/spinner @siteplane/tooltip @siteplane/traceUsage
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
} from "@/siteplane/components/message-scroller";
<div className="h-96">
<MessageScroller>
<MessageScrollerContent>
<MessageScrollerItem messageId="question" scrollAnchor>
Which accounts are likely to churn this month?
</MessageScrollerItem>
</MessageScrollerContent>
<MessageScrollerButton />
</MessageScroller>
</div>;Composition
MessageScroller
├── MessageScrollerContent
│ └── MessageScrollerItem (one per row: a turn, a marker or a pending reply)
└── MessageScrollerButtonEach direct child of MessageScrollerContent is a MessageScrollerItem, with the turns themselves built from Message and Bubble. The rows sit 32 px apart. MessageScroller is a Scroll Area that fills its container, so give the container a height: a fixed one, or the free space of a flex column with min-h-0 flex-1, as the example at the top of this page does.
Turns
Set scrollAnchor on the row that starts a turn, usually the person's message. When such a row arrives at the end, the thread scrolls it near the top of the viewport and keeps 64 px of the row before it in view (scrollPreviousItemPeek), so the reply has room to grow below it. Until the reply fills the viewport, the thread holds that room below the last row and gives it up as the reply grows.
A row that arrives after the thread opened fades in with a small rise and takes its full height at once, so the thread can measure it right away. Rows that are there when the thread opens, and rows added above the first one, take their place without the entrance. Text that streams into a row grows without animation.
In the example at the top of this page, a message you send or a suggestion you pick starts a turn. The reply shows a Thinking Marker until its first words arrive and then streams in, and Stop in the Prompt Input ends it with a note that the response was stopped.
Auto scroll and the scroll button
With autoScroll, the thread follows a growing reply while the reader is at its end, within 8 px (scrollEdgeThreshold). It lets go when the reader scrolls toward the start with the wheel, a touch drag or the Arrow Up, Page Up or Home key, when they press the scrollbar, and when a command jumps to a message. Scrolling back to the end, or pressing the scroll button, takes up the follow again. Without autoScroll, a new turn still settles near the top, and the reply grows below it without moving the viewport.
MessageScrollerButton shows only while there is something to scroll toward, and appears and leaves with the kit's motion. It floats 16 px inside the edge of the scroller as a round icon button, named “Scroll to latest”. A press scrolls to the latest row. If the button had focus, focus moves to the thread as the button leaves, so a keyboard user keeps their place. With direction="start" the button leads to the first row instead, named “Scroll to first”; place that one before the content.
Opening position
A thread opens at its end. Set defaultScrollPosition="start" to open it at the first row, or "last-anchor" to open it where its last turn settled, which falls back to the end when no row has scrollAnchor or the last turn fits in the viewport.
<MessageScroller defaultScrollPosition="last-anchor">
<MessageScrollerContent>{rows}</MessageScrollerContent>
</MessageScroller>For a moment after it opens, the thread holds its opening position while images and fonts load, until the reader scrolls.
Older messages
Add older messages above the first row and keep every row's messageId the same. The thread then moves its scroll position by the height they take, so the row the reader looks at stays where it was (preserveScrollOnPrepend, on by default). Rows inserted between two rows, instead of above the first one, take their place without this correction.
The example loads a page of four rows from a button below the thread. A thread can also load them when the reader reaches the top, which scrollable.start of useMessageScroller reports by turning false. The scroll button with direction="start" appears once there is something above.
Announcements
MessageScrollerContent is a log (role="log" with aria-relevant="additions"), so screen readers read a row when it is added. Set busy while a reply is on its way: the log is aria-busy meanwhile, so screen readers can read the finished row instead of every word as it arrives. When busy turns on, the kit's shared polite live region says “Assistant is responding” once, and when it turns off, “Response complete”.
The viewport is a region named “Messages”. While the thread overflows, it is a tab stop, so the keyboard can scroll it. Replace the words with labels, or name the region with aria-label.
Commands
useMessageScroller() gives a control inside the scroller the thread's commands and the edges it can still scroll toward, for example a menu that jumps to a question. scrollToMessage(id) scrolls that row near the top, with the peek of the row before it, and returns false when no row has that id. scrollToEnd scrolls to the latest row and follows the thread from there, and scrollToStart scrolls to the first row. Each takes { behavior }, and a scroll is smooth unless you pass another behavior, such as "instant", or reduced motion is on. The commands are stable, and a component that uses the hook renders again only when an edge becomes scrollable or stops being so.
function JumpToQuestion({ messageId }: { messageId: string }) {
const { scrollToMessage } = useMessageScroller();
return (
<Button
onClick={() => scrollToMessage(messageId)}
size="sm"
variant="ghost"
>
Back to the question
</Button>
);
}The root carries data-scrollable with the edges it can scroll toward (start, end or start end), and data-autoscrolling while a smooth scroll of its own runs.
AI SDK
The parts use the AI SDK’s words for statuses, so a thread built on useChat from @ai-sdk/react maps to them one to one. The examples on this page run on a scripted stand-in with the same words, so they need no model; the code below maps the AI SDK’s messages instead.
Messages and status
| AI SDK | Message Scroller |
|---|---|
message.id | key and messageId of the message’s MessageScrollerItem |
message.role === "user" | scrollAnchor, so the person’s message starts a turn |
status is "submitted" or "streaming" | busy on MessageScrollerContent |
status, sendMessage and stop | the Prompt Input below |
"use client";
import { useChat } from "@ai-sdk/react";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
} from "@/siteplane/components/message-scroller";
import {
PromptInput,
PromptInputSubmit,
PromptInputTextarea,
PromptInputToolbar,
PromptInputToolbarGroup,
} from "@/siteplane/components/prompt-input";
export function Chat() {
const { messages, sendMessage, status, stop } = useChat();
const busy = status === "submitted" || status === "streaming";
return (
<div className="flex h-dvh flex-col">
<MessageScroller autoScroll className="min-h-0 flex-1">
<MessageScrollerContent busy={busy} className="px-6 py-8">
{messages.map((message, index) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
<ChatMessage
message={message}
streaming={
status === "streaming" && index === messages.length - 1
}
/>
</MessageScrollerItem>
))}
{status === "submitted" ? <PendingReply /> : null}
</MessageScrollerContent>
<MessageScrollerButton />
</MessageScroller>
<div className="px-6 pb-4">
<PromptInput
onStop={stop}
onSubmit={({ text }) => sendMessage({ text })}
status={status}
>
<PromptInputTextarea />
<PromptInputToolbar>
<PromptInputToolbarGroup align="end">
<PromptInputSubmit />
</PromptInputToolbarGroup>
</PromptInputToolbar>
</PromptInput>
</div>
</div>
);
}ChatMessage renders one message with Message and Bubble, as the Message page shows. Only the last message streams, and only while status is streaming, so its actions wait until then.
The pending reply
While status is submitted, the reply has no message yet. Show a row of its own after the messages, with the Thinking Marker in a Message at the start side, where the reply will appear. When the first part arrives, the reply’s own row takes its place and fades in.
import {
Marker,
MarkerContent,
MarkerIcon,
} from "@/siteplane/components/marker";
import { Message, MessageContent } from "@/siteplane/components/message";
import { MessageScrollerItem } from "@/siteplane/components/message-scroller";
import { MotionShimmer } from "@/siteplane/components/motion";
import { Spinner } from "@/siteplane/components/spinner";
export function PendingReply() {
return (
<MessageScrollerItem messageId="pending">
<Message>
<MessageContent>
<Marker role="status">
<MarkerIcon>
<Spinner />
</MarkerIcon>
<MarkerContent>
<MotionShimmer>Thinking</MotionShimmer>
</MarkerContent>
</Marker>
</MessageContent>
</Message>
</MessageScrollerItem>
);
}Loading older messages
Load older messages from your own store and put them before the others with setMessages. Their ids stay the same, so the rows already in view keep their place.
const { messages, setMessages } = useChat();
async function loadEarlier() {
const older = await fetchEarlierMessages(messages[0]?.id);
setMessages([...older, ...messages]);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
Message | boolean | false | Follows the thread as it grows while the reader is at its end. Scrolling toward the start or a jump to a message lets go; returning to the end or the scroll button takes it up again. |
Message | "end" | "start" | "last-anchor" | "end" | Where the thread opens. last-anchor opens where the last turn settled and falls back to the end when no row has scrollAnchor or the turn fits. |
Message | number | 8 | How close to an edge, in pixels, counts as being at it. |
Message | number | 64 | How much of the row before a new turn stays in view above it, in pixels. |
Message | boolean | true | Keeps the row the reader looks at in place when rows are added above the first one. |
Message | Partial<MessageScrollerLabels> | English | Replaces the words: region (Messages), scrollToEnd (Scroll to latest), scrollToStart (Scroll to first), responding (Assistant is responding) and complete (Response complete). |
Message | boolean | false | Marks the log aria-busy while a reply is on its way, and says once that the assistant is responding and once that the response is complete. |
Message | string | required | Names the row, to scroll to it and to keep it in place. Keep it stable, such as the id of the message. |
Message | boolean | false | The row starts a turn: when it arrives at the end, it settles near the top with the peek of the row before it. |
Message | "end" | "start" | "end" | Scrolls to the latest row or to the first one. The button shows only while there is something to scroll toward. |
use | MessageScrollerApi | inside MessageScroller | scrollToEnd, scrollToStart and scrollToMessage(messageId), each with { behavior }, and scrollable: { start, end }, the edges the viewport can still scroll toward. |
MessageScrollerProps takes the Scroll Area
props except viewportRef. ref goes to the root, and aria-label or
aria-labelledby names the region in place of labels.region.
MessageScrollerContent and MessageScrollerItem accept the props of a div.
MessageScrollerButton takes the Button props
except children: it is a round icon Button at the provider's control size,
named by labels.scrollToEnd or labels.scrollToStart unless you pass
aria-label, and an onClick that calls preventDefault() keeps it from
scrolling. The types MessageScrollerLabels and MessageScrollerApi are
exported with the props types.
Usage notes
The scroller measures only the MessageScrollerItem children of MessageScrollerContent, so render each row as one of them and do not wrap rows in another element. Give every row a stable messageId and use it as the React key too. With an index as the key, rows added above would look like rows added at the end, and the thread could not keep the reader's place.
Rows are not virtualized. Every row stays mounted, and rows outside the viewport skip their rendering work until they come into view. Load a long history in pages, as in Older messages.
The row entrance and every scroll are instant when the system asks for reduced motion, when html carries data-motion-reduce="true", or inside SiteplaneMotionProvider preset="reduced". Screen readers treat aria-busy differently, so check how a streaming reply is announced with the screen readers your users rely on.