Message Scroller
The conversation list that follows new messages only while you're following along, and never moves under you while you read.
then scroll up while it streams. Scroll to the top to load older messages.
Installation
npx shadcn@latest add @pixeldosa/message-scrollerThe example above also uses Message. To copy it as-is, install everything it needs:
npx shadcn@latest add @pixeldosa/message-scroller @pixeldosa/messageRequires `cn` at `@/lib/utils`. Give children stable `key`s, because history prepends are detected by the first key changing. Give the scroller a height (`h-[…]` or `flex-1` in a flex column). Set `announcement` when a reply finishes.
Usage
"use client";
import { Message, MessageContent } from "@/components/ui/message";
import { MessageScroller } from "@/components/ui/message-scroller";
<MessageScroller className="h-[600px]" announcement={lastFinished} onReachTop={loadOlder}>
{messages.map((message) => (
<Message key={message.id} role={message.role} status={message.status}>
<MessageContent>{message.content}</MessageContent>
</Message>
))}
</MessageScroller>
Why it exists
The most common chat bug: you scroll up to reread something while the assistant is still answering, and the page keeps dragging you back down. The scroller treats "should I follow?" as a question about you, not about the content.
Features
- Follows only while you're at the bottom. Scroll up and it stops. Nothing moves under you.
- Jump to latest. A pill appears when you're scrolled away, counting messages that arrived: "2 new messages".
- History loads without a jump. When older messages arrive above, your view stays exactly where it was.
- Streaming is followed too, not just new messages, because a growing answer adds no new elements.
- Announces finished messages, not tokens. Screen readers hear what you pass as
announcement. - Keyboard scrollable. The region is focusable and named "Conversation".
Examples
Full-height chat layout:
<div className="flex h-dvh flex-col">
<MessageScroller className="flex-1">{turns}</MessageScroller>
<PromptComposer onSubmit={send} />
</div>
Announce a finished reply once:
onFinish={(message) => setAnnouncement(`Assistant replied: ${message.text.slice(0, 120)}`)}
Props
| Prop | Type | Description |
|---|---|---|
children | ReactNode | Messages with stable keys. |
announcement | string | Set when a message finishes. |
onReachTop | () => void | Fires near the top. Load older messages here. |
label | string | Region name. Defaults to "Conversation". |
Accessibility
- A focusable, named region, so it can be scrolled with the keyboard.
- A polite status region carries
announcement. It isn't a live log, which would read every token. - The jump pill is a real button and uses instant scrolling under reduced motion.
When not to use
Not for feeds where the newest item belongs at the top. For a reasoning trace inside one
message, Reasoning Stream already scrolls itself the same way.
why it's built this way
Engineering Notes
Auto-scroll is where chat interfaces most often break trust: a stream drags the view down while someone is reading an earlier answer. Here 'pinned' is a fact about the reader, recomputed from their own scroll position with a 48px tolerance. Following happens only while pinned, both when children are added (layout effect) and when the last message grows during streaming (a ResizeObserver on the content, since token growth adds no children). While unpinned, added children are counted into the jump pill. A smooth jump sets a guard, so the intermediate 'not at bottom' frames of the animation don't unpin and flash the pill back. A 700ms fallback lands on the bottom even if streaming moved the target mid-animation. Prepended history is detected by the first child's key changing alongside a higher count, and compensated by adding the height delta to scrollTop in a layout effect before paint. Native `overflow-anchor` is disabled, so browsers with and without support behave identically. Accessibility uses a named, focusable region for keyboard scrolling and a separate polite status for caller-supplied announcements, because a live log would read every streamed token.
how it moves, and when it doesn't
Motion Notes
Following while pinned is instant, not smooth: animating toward a target that moves with every token lags behind and reads as sluggish. Smooth scrolling is reserved for the user's explicit 'Jump to latest', and falls back to instant under prefers-reduced-motion. The jump pill fades in at `--pd-duration-fast` on the decelerate curve when the reader leaves the bottom, and hover colour runs at `--pd-duration-instant`. History prepends are compensated before paint, so there is no visible jump. Everything becomes instant under prefers-reduced-motion.