A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.
Loading...
"use client";import * as React from "react";import { Bubble, BubbleContent } from "@/components/ui/bubble";import { Button } from "@/components/ui/button";import { Message, MessageContent } from "@/components/ui/message";import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from "@/components/ui/message-scroller";interface ChatMessage { id: string; role: "user" | "assistant"; text: string;}const initialMessages: ChatMessage[] = [ { id: "m-1", role: "user", text: "What makes a good streaming chat feel calm?" }, { id: "m-2", role: "assistant", text: "It only moves when you ask it to. If you're at the live edge it follows the reply, and the moment you scroll away it holds your place.", }, { id: "m-3", role: "user", text: "Show me what streaming looks like." },];const reply = "Each new turn anchors near the top, then the answer streams in below it. New chunks arrive without yanking you around, so you can read at your own pace while the response keeps growing token by token.";export function MessageScrollerDemo() { const [messages, setMessages] = React.useState(initialMessages); const [isStreaming, setIsStreaming] = React.useState(false); const startStream = () => { if (isStreaming) { return; } setIsStreaming(true); const id = `m-${Date.now()}`; setMessages((prev) => [...prev, { id, role: "assistant", text: "" }]); const tokens = reply.split(" "); let index = 0; const interval = setInterval(() => { index += 1; setMessages((prev) => prev.map((message) => message.id === id ? { ...message, text: tokens.slice(0, index).join(" ") } : message, ), ); if (index >= tokens.length) { clearInterval(interval); setIsStreaming(false); } }, 90); }; return ( <div className="flex h-140 w-full max-w-md flex-col gap-3"> <MessageScrollerProvider autoScroll> <MessageScroller className="flex-1 rounded-xl border bg-card"> <MessageScrollerViewport> <MessageScrollerContent className="gap-4 p-4"> {messages.map((message) => { const isUser = message.role === "user"; return ( <MessageScrollerItem key={message.id} messageId={message.id} scrollAnchor={isUser} > <Message align={isUser ? "end" : "start"}> <MessageContent> <Bubble variant={isUser ? "default" : "muted"}> <BubbleContent>{message.text || "…"}</BubbleContent> </Bubble> </MessageContent> </Message> </MessageScrollerItem> ); })} </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton /> </MessageScroller> </MessageScrollerProvider> <Button className="w-full" disabled={isStreaming} onClick={startStream} type="button" variant="secondary" > {isStreaming ? "Streaming…" : "Stream a reply"} </Button> </div> );}
Requires shadcn ≥ 4.12.0 for the scroll-fade and shimmer utilities.
About
Streaming breaks the simple "append at the bottom and scroll" model. Messages
arrive in chunks while you may still be reading, scrolling, or looking somewhere
else. The challenge is preserving the reader's place while the conversation
keeps changing. Get it wrong and the experience feels jumpy: people are pulled
to the bottom, lose context, and have to find their way back.
MessageScroller is a chat transcript scroller built for these behaviors.
MessageScrollerProvider owns the scroll state and transcript-row behavior:
opening position, streamed output, new-turn anchoring, prepended history,
visibility, and scroll controls. MessageScroller is the styled frame that
renders inside it.
It is scoped to the scroll viewport. It does not own messages, AI state,
transport, persistence, branching, or model state. Your product code stays
focused on composing messages, markers, tools, attachments, and prompt inputs.
MessageScrollerProvider — the headless root. Owns scroll state and the
behavior props for opening position, auto-scroll, anchoring, scroll commands,
and visibility tracking.
MessageScroller — the styled frame. Lays out the viewport, content, and
controls inside the provider.
MessageScrollerViewport — the scrollable element. Receives native scroll
events and preserves the visible row when older messages are prepended.
MessageScrollerContent — the transcript container. Holds the rows and
provides the live-region defaults for new messages.
MessageScrollerItem — the transcript row boundary. Wrap every direct
child of the content so the scroller can measure, anchor, preserve position,
track visibility, and jump to it.
MessageScrollerButton — the scroll control. Scrolls to the start or end
of the transcript and is inert until there is content in its direction.
Examples
Group Chat
In a group chat, the turn boundary is often a marker like "Rocky joined the
chat" rather than the user message. Because anchoring is role-independent, you
can mark any row with scrollAnchor, including a marker.
Loading...
"use client";import { ArrowRotateClockwiseIcon } from "blode-icons-react";import * as React from "react";import { Bubble, BubbleContent } from "@/components/ui/bubble";import { Button } from "@/components/ui/button";import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle,} from "@/components/ui/card";import { Marker, MarkerContent } from "@/components/ui/marker";import { Message, MessageContent, MessageHeader } from "@/components/ui/message";import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport,} from "@/components/ui/message-scroller";import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";const currentUser = "Grace";type GroupChatItem = | { id: string; type: "event"; text: string; scrollAnchor?: boolean; } | { id: string; type: "message"; sender: string; role: "assistant" | "participant"; text: string; scrollAnchor?: boolean; };const initialItems = [ { id: "group-1", role: "participant", sender: "Grace", text: "@mary, the astrophage line keeps matching Venus energy output. Can you check my math?", type: "message", }, { id: "group-2", role: "assistant", sender: "Mary (Agent)", text: "Yes. Confirmed. The curve points to a microorganism harvesting stellar energy and breeding near carbon dioxide. If @rocky agrees, this is the clue we need.", type: "message", }, { id: "group-3", role: "participant", scrollAnchor: true, sender: "Grace", text: "ping @rocky", type: "message", },] satisfies GroupChatItem[];const rockyMarker = { id: "group-4", scrollAnchor: true, text: "Rocky has joined the chat", type: "event",} satisfies GroupChatItem;const rockyMessage = { id: "group-5", role: "participant", sender: "Rocky", text: "Amaze. Astrophage eats light, makes heat, goes to carbon dioxide. Rocky has fuel model. Grace is smart.", type: "message",} satisfies GroupChatItem;const GroupChatMessage = ({ item }: { item: Extract<GroupChatItem, { type: "message" }> }) => { const isCurrentUser = item.sender === currentUser; let variant: "muted" | "ghost" | "tinted" = "tinted"; if (isCurrentUser) { variant = "muted"; } else if (item.role === "assistant") { variant = "ghost"; } return ( <MessageScrollerItem messageId={item.id} scrollAnchor={item.scrollAnchor}> <Message align={isCurrentUser ? "end" : "start"}> <MessageContent> {!isCurrentUser && <MessageHeader>{item.sender}</MessageHeader>} <Bubble variant={variant}> <BubbleContent>{item.text}</BubbleContent> </Bubble> </MessageContent> </Message> </MessageScrollerItem> );};const GroupChatMarker = ({ item, scrollAnchor = false,}: { item: Extract<GroupChatItem, { type: "event" }>; scrollAnchor?: boolean;}) => ( <MessageScrollerItem scrollAnchor={scrollAnchor}> <Marker variant="separator"> <MarkerContent>{item.text}</MarkerContent> </Marker> </MessageScrollerItem>);export function MessageScrollerGroupChat() { const [demoKey, setDemoKey] = React.useState(0); const [rockyTurn, setRockyTurn] = React.useState<"idle" | "marker" | "message">("idle"); let items: GroupChatItem[] = initialItems; if (rockyTurn === "message") { items = [...initialItems, rockyMarker, rockyMessage]; } else if (rockyTurn === "marker") { items = [...initialItems, rockyMarker]; } const buttonLabel = rockyTurn === "idle" ? "Add Rocky" : "Send Message as Rocky"; const isComplete = rockyTurn === "message"; return ( <div className="relative flex flex-col gap-4"> <Card className="mx-auto h-140 w-full max-w-sm gap-0"> <CardHeader className="gap-1 border-b"> <CardTitle>Group Chat</CardTitle> <CardDescription> A group chat with several participants and an assistant. The Marker is marked as a turn. </CardDescription> <CardAction> <Tooltip> <TooltipTrigger render={ <Button aria-label="Reset conversation" disabled={rockyTurn === "idle"} onClick={() => { setRockyTurn("idle"); setDemoKey((key) => key + 1); }} size="icon-sm" type="button" variant="outline" /> } > <ArrowRotateClockwiseIcon /> </TooltipTrigger> <TooltipContent> <p>Reset</p> </TooltipContent> </Tooltip> </CardAction> </CardHeader> <CardContent className="min-h-0 flex-1 p-0"> <MessageScrollerProvider> <MessageScroller key={demoKey}> <MessageScrollerViewport> <MessageScrollerContent className="p-4"> {items.map((item) => item.type === "message" ? ( <GroupChatMessage item={item} key={item.id} /> ) : ( <GroupChatMarker item={item} key={item.id} scrollAnchor={item.scrollAnchor} /> ), )} </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton /> </MessageScroller> </MessageScrollerProvider> </CardContent> <CardFooter className="flex flex-col items-center gap-2 border-t"> <Button className="w-full" disabled={isComplete} onClick={() => setRockyTurn((turn) => (turn === "idle" ? "marker" : "message"))} type="button" variant="secondary" > {buttonLabel} </Button> <p className="text-muted-foreground text-xs"> {rockyTurn === "idle" ? "This will create a marker and make it the anchor" : "Now send Rocky's reply into the conversation"} </p> </CardFooter> </Card> <div className="mx-auto max-w-sm text-balance px-0.5 text-center text-muted-foreground text-xs"> When a user joins, a marker is created. scrollAnchor on the marker marks it as the next turn </div> </div> );}
Opening Position
Reopening a saved thread at the absolute end often drops the reader in without
enough context. defaultScrollPosition="last-anchor" shows the last meaningful
turn instead, keyed on scrollAnchor rather than message role.
Loading...
"use client";import * as React from "react";import { Bubble, BubbleContent } from "@/components/ui/bubble";import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle,} from "@/components/ui/card";import { Message, MessageContent } from "@/components/ui/message";import { MessageScroller, MessageScrollerButton, MessageScrollerContent, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller,} from "@/components/ui/message-scroller";import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";const messages = [ { id: "open-1", role: "user", text: "This is the first message the user sent in the conversation.", }, { id: "open-2", role: "assistant", text: "Workspace creation rose 8%, but first invite completion only rose 2%.", }, { id: "open-3", role: "user", text: "This is the last message the user sent in the conversation.", }, { id: "open-4", role: "assistant", text: "Start with the invite step. Teams are creating workspaces but waiting to add collaborators.\n\nRecommended follow-up:\n\n1. Compare invite drop-off by account size.\n2. Check whether users who skip invites still return within 24 hours.\n3. Review the empty-state copy on the first project screen.\n4. Segment activation by template, since template users may not need invites right away.\n\nIf that pattern holds, the next experiment should make collaboration useful earlier instead of prompting for invites harder.", },] satisfies { id: string; role: "user" | "assistant"; text: string;}[];const positions = [ { label: "start", value: "start" }, { label: "end", value: "end" }, { label: "last-anchor", value: "last-anchor" },] satisfies { value: "start" | "end" | "last-anchor"; label: string;}[];const OpeningPositionScroller = ({ position, positionKey,}: { position: "start" | "end" | "last-anchor"; positionKey: number;}) => { const { scrollToEnd, scrollToMessage, scrollToStart } = useMessageScroller(); React.useLayoutEffect(() => { const frame = requestAnimationFrame(() => { if (position === "start") { scrollToStart({ behavior: "auto" }); return; } if (position === "end") { scrollToEnd({ behavior: "auto" }); return; } scrollToMessage("open-3", { align: "start", behavior: "auto", scrollMargin: 64, }); }); return () => { cancelAnimationFrame(frame); }; }, [position, positionKey, scrollToEnd, scrollToMessage, scrollToStart]); return ( <MessageScroller> <MessageScrollerViewport> <MessageScrollerContent className="p-4"> {messages.map((message) => { const isUserMessage = message.role === "user"; return ( <MessageScrollerItem key={message.id} messageId={message.id} scrollAnchor={isUserMessage} > <Message align={isUserMessage ? "end" : "start"}> <MessageContent> <Bubble variant={isUserMessage ? "muted" : "ghost"}> <BubbleContent className="space-y-2"> {message.text .split(/\n\s*\n/u) .map((paragraph) => paragraph.trim()) .filter(Boolean) .map((paragraph) => ( <p className="whitespace-pre-wrap" key={paragraph}> {paragraph} </p> ))} </BubbleContent> </Bubble> </MessageContent> </Message> </MessageScrollerItem> ); })} </MessageScrollerContent> </MessageScrollerViewport> <MessageScrollerButton /> </MessageScroller> );};export function MessageScrollerOpeningPosition() { const [positionKey, setPositionKey] = React.useState(0); const [position, setPosition] = React.useState<"start" | "end" | "last-anchor">("last-anchor"); return ( <div className="relative flex flex-col gap-4"> <Card className="mx-auto h-140 w-full max-w-sm gap-0"> <CardHeader className="gap-1 border-b"> <CardTitle>Opening Position</CardTitle> <CardDescription>Choose where a saved transcript opens.</CardDescription> </CardHeader> <CardContent className="flex-1 overflow-hidden p-0"> <MessageScrollerProvider> <OpeningPositionScroller position={position} positionKey={positionKey} /> </MessageScrollerProvider> </CardContent> <CardFooter className="flex items-center justify-center border-t"> <Tabs className="w-full" onValueChange={(value) => { if (value === "start" || value === "end" || value === "last-anchor") { setPosition(value); setPositionKey((key) => key + 1); } }} value={position} > <TabsList className="w-full"> {positions.map((option) => ( <TabsTrigger key={option.value} value={option.value}> {option.label} </TabsTrigger> ))} </TabsList> </Tabs> </CardFooter> </Card> <div className="mx-auto max-w-sm px-0.5 text-center text-muted-foreground text-xs"> Toggle the defaultScrollPosition to see where the transcript starts when you open the thread </div> </div> );}
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.
All commands return false when the command could not be applied.
scrollToStart and scrollToEnd return false only when the viewport is not
mounted yet. scrollToMessage returns false when the target is not mounted
and cannot be queued.
Command options:
Option
Type
Default
Description
align
"start" | "center" | "end" | "nearest"
"start"
How a message target aligns in the viewport.
behavior
ScrollBehavior
"auto"
Native scroll behavior for the command.
scrollMargin
number
provider scrollMargin
Margin applied to the aligned edge for this command.
useMessageScrollerScrollable
Which edges the viewport can scroll toward, for sibling UI that needs the values
in JavaScript. Prefer the data-scrollable attribute for styling the scroller
itself.
Value
Type
Description
start
boolean
Whether the viewport can scroll toward the start. Content is hidden above (!start means at the top).
end
boolean
Whether the viewport can scroll toward the end. Content is hidden below (!end means at the bottom).
useMessageScrollerVisibility
Visibility state for outline, search, and active-turn UI. It subscribes
separately from useMessageScrollerScrollable, so visibility work is only paid
for when a consumer needs it.
Value
Type
Description
currentAnchorId
string | null
The current anchored turn, based on the last scrollAnchor item at or above the reading line.
visibleMessageIds
string[]
Message ids intersecting the viewport, in document order.
Filter visibleMessageIds in your app when you need a narrower outline, such as
user messages, anchored turns, or search hits.