Components

Message Scroller

A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.

PreviewSource
New Chat
How can I help you today?
Morning, shadcn!
What are we working on today? Press send to start a new conversation
I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.
Demo is read only. Press send to send messages.
Example requirements

Also uses: Button, Card, Dropdown Menu, Empty, Input Group, Tooltip.

bun add --exact lucide-preact@1.51.0

Copy the example’s local helpers and preserve their relative imports: chat.ts, chat.ts, message-animated.tsx.

Installation

Complete the project setup first, including Tailwind CSS v4, theme tokens, and Preact compatibility aliases.

1. Install dependencies

bun add --exact class-variance-authority@0.7.1 cn@0.4.0 lucide-preact@1.51.0 preact@11.0.0 tw-animate-css@1.4.0

The usage example also needs Message. Follow their installation guides as well.

2. Copy the component with degit

Run this from your project root. It selects message-scroller.tsx, its local dependencies, and both licenses directly from the repository, then copies them into src/components/ui.

bunx degit@3.10.0 https://github.com/LiasCode/shadcn-preact#main ./.cache/shadcn-preact/message-scroller --files LICENSE.md,registry/ui/button.tsx,registry/ui/lib/utils.ts,registry/ui/message-scroller.tsx,registry/ui/primitives/LICENSE,registry/ui/primitives/button/Button.tsx,registry/ui/primitives/button/index.ts,registry/ui/primitives/internals/ShadcnUseRender.ts,registry/ui/primitives/internals/composite/root/CompositeRootContext.ts,registry/ui/primitives/internals/empty.ts,registry/ui/primitives/internals/getReactElementRef.ts,registry/ui/primitives/internals/getStateAttributesProps.ts,registry/ui/primitives/internals/mergeObjects.ts,registry/ui/primitives/internals/resolveClassName.ts,registry/ui/primitives/internals/resolveStyle.ts,registry/ui/primitives/internals/types.ts,registry/ui/primitives/internals/useButton.ts,registry/ui/primitives/internals/useFocusableWhenDisabled.ts,registry/ui/primitives/internals/useIsoLayoutEffect.ts,registry/ui/primitives/internals/useMergedRefs.ts,registry/ui/primitives/internals/useRefWithInit.ts,registry/ui/primitives/internals/useRenderElement.ts,registry/ui/primitives/internals/useStableCallback.ts,registry/ui/primitives/merge-props/index.ts,registry/ui/primitives/message-scroller/components.tsx,registry/ui/primitives/message-scroller/geometry.ts,registry/ui/primitives/message-scroller/index.ts,registry/ui/primitives/message-scroller/stores.ts,registry/ui/primitives/message-scroller/types.ts,registry/ui/primitives/message-scroller/use-message-scroller-commands.ts,registry/ui/primitives/message-scroller/use-message-scroller-controller.ts,registry/ui/primitives/message-scroller/use-message-scroller-refs.ts,registry/ui/primitives/message-scroller/utils.ts --force
mkdir -p ./src/components/ui
cp -R ./.cache/shadcn-preact/message-scroller/registry/ui/. ./src/components/ui/
cp ./.cache/shadcn-preact/message-scroller/LICENSE.md ./src/components/ui/LICENSE.md

The temporary copy lives in .cache/shadcn-preact/message-scroller. Existing components stay in place; shared source files are updated from main.

Selected source files (32)

Usage

import { Message } from "./components/ui/message"
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "./components/ui/message-scroller"
<MessageScrollerProvider>
  <MessageScroller>
    <MessageScrollerViewport>
      <MessageScrollerContent>
        {messages.map((message) => (
          <MessageScrollerItem
            key={message.id}
            messageId={message.id}
            scrollAnchor={message.role === "user"}
          >
            <Message />
          </MessageScrollerItem>
        ))}
      </MessageScrollerContent>
    </MessageScrollerViewport>
    <MessageScrollerButton />
  </MessageScroller>
</MessageScrollerProvider>
<div className="flex h-screen flex-col">
  <MessageScrollerProvider>
    <MessageScroller className="flex-1">{/* transcript */}</MessageScroller>
  </MessageScrollerProvider>
</div>

Examples

Anchoring Turns

A turn is the part of the conversation that starts a new exchange. In a simple AI chat, that is usually the user's message and the assistant reply that follows.

Anchoring TurnsSource
Example requirements

Also uses: Button, Card, Empty, Toggle Group.

bun add --exact lucide-preact@1.51.0

Copy the example’s local helpers and preserve their relative imports: message-animated.tsx.

Group Chat

In a group chat, the turn boundary is more specific than "the user message". It is often the message that asks the model to respond, or a marker like "Marcus joined the chat". Typing indicators and history controls usually should not anchor.

Group ChatSource
Example requirements

Also uses: Bubble, Button, Card, Marker, Message, Tooltip.

bun add --exact lucide-preact@1.51.0

Keeping Context Visible

When a new turn starts, it should still feel like part of the same continuous thread. scrollPreviousItemPeek keeps a slice of the previous item visible above the anchor, so the reader keeps their context instead of feeling like the conversation restarted on a blank page.

Keeping Context VisibleSource
Example requirements

Also uses: Button, Card, Dropdown Menu, Input Group, Slider, Tooltip.

bun add --exact lucide-preact@1.51.0

Copy the example’s local helpers and preserve their relative imports: chat.ts, chat.ts, message-animated.tsx.

Following the Live Edge

When the reader is at the live edge, either because they stayed there or returned there, autoScroll keeps streamed replies in view as they grow. Scrolling away from the live edge releases the view, whether by wheel, touch, keyboard scroll keys, or dragging the scrollbar. An explicit message jump releases it too. New chunks can then arrive without moving the reader.

Following the Live EdgeSource
Example requirements

Also uses: Button, Card, Dropdown Menu, Empty, Input Group, Tooltip.

bun add --exact lucide-preact@1.51.0

Copy the example’s local helpers and preserve their relative imports: chat.ts, chat.ts, message-animated.tsx.

Opening Saved Threads

It can seem reasonable to reopen a saved thread at the absolute end of the transcript, but that often drops the reader into the conversation without enough context. A better default is "last-anchor": show the last meaningful turn, like the user's latest message, with the reply below it.

Opening Saved ThreadsSource
Example requirements

Also uses: Bubble, Card, Message, Tabs.

Loading Earlier Messages

Loading earlier messages should not move the conversation the reader is already looking at. When older rows are prepended above the current transcript, MessageScrollerViewport preserves the visible row so the reader stays in the same place while history loads above them.

Loading Earlier MessagesSource
Example requirements

Also uses: Bubble, Button, Card, Marker, Message, Tooltip.

bun add --exact lucide-preact@1.51.0 sonner@2.0.8

Copy the example’s local helpers and preserve their relative imports: chat.ts.

Animating New Messages

A common chat pattern is to animate the user's message when it is sent, then let the assistant reply stream into a regular row below it. Start the user row below its final position so it feels like it rises from the live edge of the viewport.

Animating New MessagesSource
Example requirements

Also uses: Button, Card, Empty, Select.

bun add --exact lucide-preact@1.51.0

Copy the example’s local helpers and preserve their relative imports: chat.ts, chat.ts, message-animated.tsx, message-animations.ts.

Jumping to Messages

Search results, permalinks, outline items, and toolbar buttons often need to drive the transcript from outside the message list. Use useMessageScroller for those controls. Because the hooks read from MessageScrollerProvider, they work in any component inside the provider, including controls rendered outside the MessageScroller frame.

Jumping to MessagesSource
Example requirements

Also uses: Bubble, Button, Card, Dropdown Menu, Message.

Copy the example’s local helpers and preserve their relative imports: chat.ts.

Tracking the Reader's Position

Use useMessageScrollerVisibility to track the reader's position in the conversation. A common example is a table-of-contents or a jump menu that highlights the current anchored turn.

Tracking the Reader's PositionSource
Example requirements

Also uses: Bubble, Card, Hover Card, Message.

Copy the example’s local helpers and preserve their relative imports: chat.ts.

Reading Scroll State

Use useMessageScrollerScrollable when you need scroll state in JavaScript, such as a status indicator or a custom "jump to latest" control. It reports which edges the viewport can still scroll toward; "at the start/end" is the negation (!start / !end), and "scrollable at all" is start || end. For styling the scroller itself, prefer the data-scrollable attribute.

Reading Scroll StateSource
Example requirements

Also uses: Card.

Copy the example’s local helpers and preserve their relative imports: message-animated.tsx.

State

StateSource
Example requirements

Also uses: Bubble, Card, Message.

Reference

See the official Message Scroller documentation for composition patterns and API details. This page uses the Preact port and examples from the pinned upstream revision.

Component source