- Accordion
- Alert
- Alert Dialog
- Ask User Questions
- Aspect Ratio
- Attachment
- Autocomplete
- Avatar
- Badge
- Bar List
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Chat Message
- Checkbox
- Checkbox Group
- Circular Progress
- Collapsible
- Combobox
- Command
- Context Menu
- Copy Button
- Currency Input
- Data Table
- Date Picker
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Input
- Input Group
- Input Message
- Input OTP
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Multi Combobox
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Phone Input
- Popover
- Progress
- Progress List
- Prompt
- Radio Group
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner
- Spinner
- Stat
- Switch
- Table
- Tabs
- Textarea
- Thinking Indicator
- Thinking Steps
- Toggle
- Toggle Group
- Tooltip
- Typography
"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.
Installation
npx shadcn@latest add "https://ui.blode.co/r/styles/default/message-scroller"
Usage
import { Message } from "@/components/ui/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller";<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
<Message />
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>MessageScroller fills its parent, so place it inside a height-constrained
container.
<div className="flex h-screen flex-col">
<MessageScrollerProvider>
<MessageScroller className="flex-1">{/* transcript */}</MessageScroller>
</MessageScrollerProvider>
</div>Composition
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.
"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.
"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.
"use client";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Message, MessageContent } from "@/components/ui/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScrollerScrollable,
} from "@/components/ui/message-scroller";
const messages = Array.from({ length: 12 }, (_, index) => ({
id: `state-${index + 1}`,
role: index % 2 === 0 ? "user" : "assistant",
text:
index % 2 === 0
? `Check section ${index + 1} of the transcript.`
: `Section ${index + 1} is ready. Scroll state updates without rerendering the rows.`,
})) satisfies {
id: string;
role: "user" | "assistant";
text: string;
}[];
const StatusBar = () => {
const { start, end } = useMessageScrollerScrollable();
const states = [
{ label: "At top", on: !start },
{ label: "At bottom", on: !end },
{ label: "Older above", on: start },
{ label: "Newer below", on: end },
];
return (
<div className="pointer-events-none absolute inset-x-3 top-3 z-10 flex flex-wrap gap-1.5">
{states.map((state) => (
<span
className="rounded-full border bg-background px-2 py-0.5 text-muted-foreground text-xs data-[on=true]:border-transparent data-[on=true]:bg-primary data-[on=true]:text-primary-foreground"
data-on={state.on}
key={state.label}
>
{state.label}
</span>
))}
</div>
);
};
export function MessageScrollerState() {
return (
<Card className="mx-auto h-112 w-full max-w-md gap-0">
<CardHeader className="border-b">
<CardTitle>Scroll State</CardTitle>
<CardDescription>Read scroll state in JavaScript with the state hook.</CardDescription>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<StatusBar />
<MessageScrollerViewport>
<MessageScrollerContent className="gap-4 p-4 pt-12">
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
<Message align={message.role === "user" ? "end" : "start"}>
<MessageContent>
<Bubble variant={message.role === "user" ? "default" : "muted"}>
<BubbleContent>{message.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</CardContent>
</Card>
);
}API Reference
useMessageScroller
Imperative transcript controls.
| Method | Type | Description |
|---|---|---|
scrollToMessage | (messageId: string, options?) => boolean | Scroll to a mounted message id. |
scrollToEnd | (options?) => boolean | Scroll to the latest message. |
scrollToStart | (options?) => boolean | Scroll to the top. |
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.