Message Scroller
A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.
Example requirements
Also uses: Button, Card, Dropdown Menu, Empty, Input Group, Tooltip.
bun add --exact lucide-preact@1.51.0Copy 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.0The 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.mdThe 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)
- button.tsx
- lib/utils.ts
- message-scroller.tsx
- primitives/LICENSE
- primitives/button/Button.tsx
- primitives/button/index.ts
- primitives/internals/ShadcnUseRender.ts
- primitives/internals/composite/root/CompositeRootContext.ts
- primitives/internals/empty.ts
- primitives/internals/getReactElementRef.ts
- primitives/internals/getStateAttributesProps.ts
- primitives/internals/mergeObjects.ts
- primitives/internals/resolveClassName.ts
- primitives/internals/resolveStyle.ts
- primitives/internals/types.ts
- primitives/internals/useButton.ts
- primitives/internals/useFocusableWhenDisabled.ts
- primitives/internals/useIsoLayoutEffect.ts
- primitives/internals/useMergedRefs.ts
- primitives/internals/useRefWithInit.ts
- primitives/internals/useRenderElement.ts
- primitives/internals/useStableCallback.ts
- primitives/merge-props/index.ts
- primitives/message-scroller/components.tsx
- primitives/message-scroller/geometry.ts
- primitives/message-scroller/index.ts
- primitives/message-scroller/stores.ts
- primitives/message-scroller/types.ts
- primitives/message-scroller/use-message-scroller-commands.ts
- primitives/message-scroller/use-message-scroller-controller.ts
- primitives/message-scroller/use-message-scroller-refs.ts
- primitives/message-scroller/utils.ts
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.
Example requirements
Also uses: Button, Card, Empty, Toggle Group.
bun add --exact lucide-preact@1.51.0Copy 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.
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.
Example requirements
Also uses: Button, Card, Dropdown Menu, Input Group, Slider, Tooltip.
bun add --exact lucide-preact@1.51.0Copy 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.
Example requirements
Also uses: Button, Card, Dropdown Menu, Empty, Input Group, Tooltip.
bun add --exact lucide-preact@1.51.0Copy 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.
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.
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.
Example requirements
Also uses: Button, Card, Empty, Select.
bun add --exact lucide-preact@1.51.0Copy 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.
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.
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.
Example requirements
Also uses: Card.
Copy the example’s local helpers and preserve their relative imports: message-animated.tsx.
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