PixelDosa UI

Message Scroller

The conversation list that follows new messages only while you're following along, and never moves under you while you read.

What changed in the 4.2 release?
Assistant

4.2 added a guided setup checklist for new workspaces, faster CSV imports, and SSO for the Team plan.

Which of those affects admins?
Assistant

SSO does most directly: admins configure it under Settings → Security. The checklist also appears for admins the first time they open a new workspace.

then scroll up while it streams. Scroll to the top to load older messages.

Installation

bash
npx shadcn@latest add @pixeldosa/message-scroller

The example above also uses Message. To copy it as-is, install everything it needs:

bash
npx shadcn@latest add @pixeldosa/message-scroller @pixeldosa/message

Requires `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

PropTypeDescription
childrenReactNodeMessages with stable keys.
announcementstringSet when a message finishes.
onReachTop() => voidFires near the top. Load older messages here.
labelstringRegion 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.