- Accordion
- Alert
- Alert Dialog
- Attachment
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Checkbox
- Collapsible
- Command
- Context Menu
- Data Table
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Input
- Input Group
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Pagination
- Popover
- Progress
- Questionnaire
- Radio Group
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toggle
- Toggle Group
- Tooltip
A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.
Every time the assistant streams a reply, the whole thread jumps around. How do I stop that?
Wrap the transcript in MessageScroller and turn on autoScroll. The view follows new text only while you are already at the bottom.
Scroll up to read something earlier and it lets go: new tokens keep arriving below without moving what you are reading.
"use client"
import { ArrowUpIcon, RotateCwIcon } from "lucide-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 {
InputGroup,
InputGroupAddon,
InputGroupButton,
} from "@/components/ui/input-group"
import { Message, MessageContent } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useScriptedChat } from "@/examples/message-scroller/scripted-chat"
const script = [
{
user: "Every time the assistant streams a reply, the whole thread jumps around. How do I stop that?",
assistant:
"Wrap the transcript in MessageScroller and turn on autoScroll. The view follows new text only while you are already at the bottom.\n\nScroll up to read something earlier and it lets go: new tokens keep arriving below without moving what you are reading.",
},
{
user: "Sending a new message still feels jarring.",
assistant:
"Mark the turn that starts an exchange with scrollAnchor. When it is appended, the viewport settles it near the top and leaves a peek of the previous reply above it.\n\nThe answer then streams into the room below, instead of the page snapping to the bottom on every token.",
},
{
user: "And if someone has scrolled away?",
assistant:
"They stay where they are. MessageScrollerButton appears at the bottom of the viewport when there is newer content; pressing it jumps to the latest message and starts following again.",
},
{
user: "Does this work with assistive tech?",
assistant:
'The content is a log with aria-relevant="additions", so new rows are announced without reading every streamed token. The scroll button is a real button that leaves the tab order when there is nothing to scroll to.',
},
] as const
export function MessageScrollerDemo() {
const { messages, next, send, reset, status } = useScriptedChat(script, {
initialTurns: 1,
})
const isBusy = status === "streaming"
return (
<MessageScrollerProvider autoScroll>
<Card className="h-[34rem] w-full max-w-sm gap-0">
<CardHeader className="border-b">
<CardTitle>New chat</CardTitle>
<CardDescription>Press send to play the next turn.</CardDescription>
<CardAction>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Reset conversation"
disabled={isBusy}
onClick={reset}
/>
}
>
<RotateCwIcon />
</TooltipTrigger>
<TooltipContent>Reset</TooltipContent>
</Tooltip>
</CardAction>
</CardHeader>
<CardContent className="min-h-0 flex-1 px-0">
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent
aria-busy={isBusy}
className="p-(--card-spacing)"
>
{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" : "ghost"
}
>
<BubbleContent className="flex flex-col gap-2">
{message.text.split("\n\n").map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</CardContent>
<CardFooter className="border-t-0 bg-transparent pt-0">
<form
className="w-full"
onSubmit={(event) => {
event.preventDefault()
send()
}}
>
<InputGroup className="h-auto">
<p className="line-clamp-2 min-h-10 w-full px-2.5 pt-2 text-sm text-muted-foreground">
{next?.user ?? "That's the whole script. Reset to replay it."}
</p>
<InputGroupAddon align="block-end" className="justify-end">
<InputGroupButton
type="submit"
variant="default"
size="icon-sm"
className="rounded-full"
disabled={!next || isBusy}
aria-label="Send"
>
<ArrowUpIcon />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
</CardFooter>
</Card>
</MessageScrollerProvider>
)
}MessageScroller is the scroll container for a chat transcript. It follows a streamed reply only while the reader is at the live edge, settles each new turn near the top with a peek of the previous one, keeps the reader's place when history loads above, and can jump to any message. It owns scrolling only: messages, AI state, transport and persistence stay in your code.
The behavior comes from @shadcn/react; this component is the styled frame around it.
Installation#
pnpm dlx shadcn@latest add https://ui.ikramhasan.com/r/message-scroller.json
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>MessageScroller fills its parent, so put it in a container with a height.
Composition#
MessageScrollerProvider
└── MessageScroller
├── MessageScrollerViewport
│ └── MessageScrollerContent
│ └── MessageScrollerItem
└── MessageScrollerButtonMessageScrollerProvider: the headless root. Owns the scroll state and the behavior props. The hooks work anywhere inside it, including outside the frame.MessageScroller: the frame that holds the viewport and the button.MessageScrollerViewport: the scrolling element, with the thin scrollbar and a fade at the bottom edge.MessageScrollerContent: the transcript, arole="log"live region. Rows are 24px apart.MessageScrollerItem: one row (a message, marker, typing indicator or "load earlier" row). Wrap every direct child of the content.MessageScrollerButton: a round raised key, 16px from the edge, that scrolls to the end (or start) and only appears when there is something there.
Examples#
Anchoring and Streaming#
Mark the row that starts a turn with scrollAnchor. When one is appended, the viewport settles it near the top, keeping scrollPreviousItemPeek pixels of the previous row in view, and the reply streams into the space below. With autoScroll, the view follows the reply while the reader is at the live edge; scrolling away (wheel, touch, keys, scrollbar) releases it, and MessageScrollerButton or scrollToEnd brings it back. The demo above uses both.
<MessageScrollerProvider autoScroll scrollPreviousItemPeek={64}>
{/* … */}
</MessageScrollerProvider>Loading Earlier Messages#
Prepending rows keeps the visible row in place (preserveScrollOnPrepend, on by default). Give rows stable messageIds so the scroller knows which one to keep.
Do we need to roll back?
Not yet. Queue depth is recovering after we reduced retry concurrency, and the oldest pending job is now under five minutes old.
Keep the rollback ready if the queue starts climbing again.
Keep watching for customer-visible issues.
I will watch the queue and support tags for another 15 minutes. If they stay quiet through the next batch window, we can close this as an internal degradation.
"use client"
import * as React from "react"
import { RotateCwIcon } from "lucide-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 } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
import { history } from "@/examples/message-scroller/history"
const INITIAL_VISIBLE_COUNT = 4
export function MessageScrollerLoadHistory() {
const [demoKey, setDemoKey] = React.useState(0)
const [visibleCount, setVisibleCount] = React.useState(INITIAL_VISIBLE_COUNT)
const visibleMessages = history.slice(-visibleCount)
const canLoadHistory = visibleCount < history.length
return (
<MessageScrollerProvider defaultScrollPosition="end">
<Card className="h-[30rem] w-full max-w-sm gap-0">
<CardHeader className="border-b">
<CardTitle>Load history</CardTitle>
<CardDescription>Earlier messages keep your place.</CardDescription>
<CardAction>
<Button
variant="ghost"
size="icon-sm"
aria-label="Reset loaded messages"
disabled={visibleCount === INITIAL_VISIBLE_COUNT}
onClick={() => {
setVisibleCount(INITIAL_VISIBLE_COUNT)
setDemoKey((key) => key + 1)
}}
>
<RotateCwIcon />
</Button>
</CardAction>
</CardHeader>
<CardContent className="min-h-0 flex-1 px-0">
<MessageScroller key={demoKey}>
<MessageScrollerViewport>
<MessageScrollerContent className="p-(--card-spacing)">
{visibleMessages.map((message) => (
<MessageScrollerItem key={message.id} messageId={message.id}>
<Message align={message.role === "user" ? "end" : "start"}>
<MessageContent>
<Bubble
variant={message.role === "user" ? "muted" : "ghost"}
>
<BubbleContent className="flex flex-col gap-2">
{message.text.split("\n\n").map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
<MessageScrollerItem>
<Marker variant="separator">
<MarkerContent>End of conversation</MarkerContent>
</Marker>
</MessageScrollerItem>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</CardContent>
<CardFooter>
<Button
variant="secondary"
className="w-full"
disabled={!canLoadHistory}
onClick={() => setVisibleCount(history.length)}
>
{canLoadHistory ? "Load earlier messages" : "History loaded"}
</Button>
</CardFooter>
</Card>
</MessageScrollerProvider>
)
}Jumping to Messages#
useMessageScroller returns scrollToMessage, scrollToStart and scrollToEnd. useMessageScrollerVisibility reports currentAnchorId (the turn you are in) and visibleMessageIds, so an outline can follow along.
Can you summarize the incident channel?
The first alert was a delayed export job. It started backing up around 09:42 UTC and fired once the retry queue crossed its threshold.
Checkout was not affected, but exports for larger workspaces ran about 12 minutes behind.
Was checkout affected?
No checkout errors were reported. Payment authorization, order creation and confirmation emails stayed inside their normal latency.
What changed in the last deploy?
Only the export queue worker. The deploy moved large CSV jobs onto the shared retry policy, so each failed attempt held a worker slot longer than before.
Do we need to roll back?
Not yet. Queue depth is recovering after we reduced retry concurrency, and the oldest pending job is now under five minutes old.
Keep the rollback ready if the queue starts climbing again.
Keep watching for customer-visible issues.
I will watch the queue and support tags for another 15 minutes. If they stay quiet through the next batch window, we can close this as an internal degradation.
"use client"
import { ArrowDownToLineIcon, ArrowUpToLineIcon } from "lucide-react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Message, MessageContent } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
useMessageScrollerVisibility,
} from "@/components/ui/message-scroller"
import { history } from "@/examples/message-scroller/history"
const questions = history.filter((message) => message.role === "user")
// Controls outside the scroller frame, driving it through the hooks.
function Outline() {
const { scrollToMessage, scrollToStart, scrollToEnd } = useMessageScroller()
const { currentAnchorId } = useMessageScrollerVisibility()
return (
<div className="flex w-full flex-col gap-3">
<nav aria-label="Questions" className="-mx-2 flex flex-col">
{questions.map((question, index) => (
<Button
key={question.id}
variant="ghost"
className="justify-start text-muted-foreground aria-current:bg-accent aria-current:text-foreground"
aria-current={question.id === currentAnchorId ? "true" : undefined}
onClick={() => scrollToMessage(question.id, { behavior: "smooth" })}
>
<span className="tabular-nums">{index + 1}.</span>
<span className="truncate">{question.text}</span>
</Button>
))}
</nav>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
className="flex-1"
onClick={() => scrollToStart({ behavior: "smooth" })}
>
<ArrowUpToLineIcon data-icon="inline-start" />
Start
</Button>
<Button
variant="secondary"
size="sm"
className="flex-1"
onClick={() => scrollToEnd({ behavior: "smooth" })}
>
<ArrowDownToLineIcon data-icon="inline-start" />
End
</Button>
</div>
</div>
)
}
export function MessageScrollerCommands() {
return (
<MessageScrollerProvider defaultScrollPosition="start">
<Card className="h-[34rem] w-full max-w-sm gap-0 py-0">
<CardContent className="min-h-0 flex-1 px-0">
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent className="p-(--card-spacing)">
{history.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" ? "muted" : "ghost"}
>
<BubbleContent className="flex flex-col gap-2">
{message.text.split("\n\n").map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>
</CardContent>
<CardFooter>
<Outline />
</CardFooter>
</Card>
</MessageScrollerProvider>
)
}Scroll State#
useMessageScrollerScrollable reports whether the viewport can still scroll toward each edge. For styling, prefer the data-scrollable attribute on the root and viewport.
Can you summarize the incident channel?
The first alert was a delayed export job. It started backing up around 09:42 UTC and fired once the retry queue crossed its threshold.
Checkout was not affected, but exports for larger workspaces ran about 12 minutes behind.
Was checkout affected?
No checkout errors were reported. Payment authorization, order creation and confirmation emails stayed inside their normal latency.
What changed in the last deploy?
Only the export queue worker. The deploy moved large CSV jobs onto the shared retry policy, so each failed attempt held a worker slot longer than before.
Do we need to roll back?
Not yet. Queue depth is recovering after we reduced retry concurrency, and the oldest pending job is now under five minutes old.
Keep the rollback ready if the queue starts climbing again.
Keep watching for customer-visible issues.
I will watch the queue and support tags for another 15 minutes. If they stay quiet through the next batch window, we can close this as an internal degradation.
"use client"
import { Badge } from "@/components/ui/badge"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Message, MessageContent } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScrollerScrollable,
} from "@/components/ui/message-scroller"
import { history } from "@/examples/message-scroller/history"
function ScrollState() {
const { start, end } = useMessageScrollerScrollable()
return (
<div className="flex w-full items-center gap-2 text-sm text-muted-foreground">
<span className="flex-1">Scroll the transcript</span>
<Badge variant={start ? "default" : "secondary"}>
{start ? "More above" : "At start"}
</Badge>
<Badge variant={end ? "default" : "secondary"}>
{end ? "More below" : "At end"}
</Badge>
</div>
)
}
export function MessageScrollerScrollable() {
return (
<MessageScrollerProvider defaultScrollPosition="last-anchor">
<Card className="h-[30rem] w-full max-w-sm gap-0 py-0">
<CardContent className="min-h-0 flex-1 px-0">
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent className="p-(--card-spacing)">
{history.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" ? "muted" : "ghost"}
>
<BubbleContent className="flex flex-col gap-2">
{message.text.split("\n\n").map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton direction="start" />
<MessageScrollerButton />
</MessageScroller>
</CardContent>
<CardFooter>
<ScrollState />
</CardFooter>
</Card>
</MessageScrollerProvider>
)
}Opening Saved Threads#
defaultScrollPosition sets where a transcript opens: "start", "end", or "last-anchor" (the last anchored turn with its reply below, falling back to "end"). Until that position is applied the viewport carries data-pending-scroll and stays hidden, so the reader never sees the jump. The Scroll State example above opens at "last-anchor".
Accessibility#
- The viewport is a focusable, labeled region (
role="region",aria-label="Messages",tabIndex={0}), so keyboard users can scroll it. Focus shows a 2pxringoutline inset 2px. - The content is a
role="log"witharia-relevant="additions": new rows are announced, streamed text is not read token by token. Passaria-busywhile a turn streams to hold announcements until it is complete. - The button is a real button with a "Scroll to end" / "Scroll to start" label. When there is nothing to scroll to, it is
inert, out of the tab order anddata-active="false". - Reduced motion keeps the button's fade and drops its rise and scale.
API Reference#
MessageScrollerProvider#
| Prop | Type | Default | Description |
|---|---|---|---|
autoScroll | boolean | false | Follow streamed output while the reader is at the live edge. |
defaultScrollPosition | "start" | "end" | "last-anchor" | "end" | Where the transcript opens. |
scrollPreviousItemPeek | number | 64 | Pixels of the previous row kept visible above a new anchor. |
scrollMargin | number | 0 | Space kept between a scrolled-to row and the viewport edge. |
scrollEdgeThreshold | number | 8 | How close to an edge counts as being at it. |
Parts#
| Part | Props |
|---|---|
MessageScroller | div props. |
MessageScrollerViewport | preserveScrollOnPrepend (default true). Exposes data-scrollable, data-autoscrolling, data-pending-scroll. |
MessageScrollerContent | spacerClassName. |
MessageScrollerItem | messageId, scrollAnchor (default false). |
MessageScrollerButton | direction: start · end (default end), behavior, render, plus Button variant (default secondary) and size (default icon-sm). Exposes data-active, data-direction. |
Hooks#
| Hook | Returns |
|---|---|
useMessageScroller | { scrollToMessage(id, options), scrollToStart(options), scrollToEnd(options) }; options take align, behavior, scrollMargin. |
useMessageScrollerScrollable | { start, end }: whether the viewport can scroll toward each edge. |
useMessageScrollerVisibility | { currentAnchorId, visibleMessageIds }. |