mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-09 06:55:07 +00:00
* fix(react): follow the live edge after an anchored reply fills the viewport Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186fcQpHo1aLQC4wRpwKFFu * fix(react): keep the scroll button quiet while follow-output is engaged Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186fcQpHo1aLQC4wRpwKFFu * fix(v4): wire base commands example menu items with onClick Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186fcQpHo1aLQC4wRpwKFFu * style: format --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
592 lines
22 KiB
Plaintext
592 lines
22 KiB
Plaintext
---
|
||
title: Message Scroller
|
||
description: A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.
|
||
base: base
|
||
component: true
|
||
---
|
||
|
||
<ComponentPreview
|
||
styleName="base-rhea"
|
||
name="message-scroller-demo"
|
||
className="rounded-[34px] sm:rounded-4xl"
|
||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||
/>
|
||
|
||
## What Makes a Great Streaming Chat Experience
|
||
|
||
Building a chat interface used to be simple. You create an inverted list with
|
||
an input. Type a message, it appends at the bottom. When a reply comes in, the
|
||
list grows and scrolls. Done.
|
||
|
||
Streaming breaks that model. Messages arrive in chunks while you may still be
|
||
reading, scrolling, or looking somewhere else entirely.
|
||
|
||
Now the challenge is preserving the reader's place while the conversation keeps
|
||
changing. Get that wrong and the experience feels jumpy: people are pulled to
|
||
the bottom, lose context, and have to find their way back.
|
||
|
||
In practice, this comes down to scroll: when to follow, when to hold, and when
|
||
to let the reader decide. A great streaming chat should:
|
||
|
||
1. **Move only when the reader asked to move.** If someone is reading, don’t pull them somewhere else. Auto-scroll should never be the default.
|
||
2. **Follow only while they’re following.** If they’re at the live edge, keep the stream in view. If they scroll away, leave them there.
|
||
3. **Every interaction is a signal.** Scrolling is not the only one. Selecting text, using the keyboard, opening a link, or searching should all stop the interface from moving.
|
||
4. **Start a new turn near the top of the viewport.** This gives the new turn somewhere it can be read from the beginning.
|
||
5. **Then stream in the answer.** The answer should grow into the screen, not immediately push everything away.
|
||
6. **Keep part of the previous conversation in context.** The prompt and reply should stay visually connected, and enough of the previous turn should remain visible so the reader knows where they are.
|
||
7. **Let new content arrive offscreen.** The conversation can keep streaming without changing what the reader is looking at.
|
||
8. **Show what’s happening out of view.** Make it clear when a response is still streaming or when new messages have arrived.
|
||
9. **Make it easy to return to the latest reply.** A “Jump to latest” action should bring the reader back and resume following.
|
||
10. **Let people jump anywhere in the conversation.** Long threads need message links, search, unread markers, and direct navigation.
|
||
11. **Reopen where the reader left off.** A saved conversation should open at the last meaningful turn. Often this is the last user message. Not the absolute bottom.
|
||
12. **Keep the reader’s place when layout changes.** Images load. Markdown expands. Code blocks render. Older messages appear above. None of that should make the reader lose their place.
|
||
13. **Handle interruptions without stealing position.** Stopping, retrying, regenerating, branching, or errors should not unexpectedly move the conversation.
|
||
14. **Stay responsive in long threads.** Streaming text, markdown, code, images, and long history should still feel responsive.
|
||
15. **Be accessible without the noise.** Keep the transcript navigable, preserve keyboard focus, and announce important events at a comfortable pace.
|
||
|
||
**Never move the reader against their intent.**
|
||
|
||
## MessageScroller
|
||
|
||
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.
|
||
|
||
MessageScroller 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.
|
||
|
||
It gives you the scroll behavior that chat needs, without taking over the rest
|
||
of the chat UI. And it stays fast, even in long conversations with rich
|
||
markdown.
|
||
|
||
## Installation
|
||
|
||
<CodeTabs>
|
||
|
||
<TabsList>
|
||
<TabsTrigger value="cli">Command</TabsTrigger>
|
||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="cli">
|
||
|
||
```bash
|
||
npx shadcn@latest add message-scroller
|
||
```
|
||
|
||
</TabsContent>
|
||
|
||
<TabsContent value="manual">
|
||
|
||
<Steps className="mb-0 pt-2">
|
||
|
||
<Step>Install the following dependencies:</Step>
|
||
|
||
```bash
|
||
npm install @shadcn/react
|
||
```
|
||
|
||
<Step>Copy and paste the following code into your project.</Step>
|
||
|
||
<ComponentSource
|
||
name="message-scroller"
|
||
title="components/ui/message-scroller.tsx"
|
||
styleName="base-nova"
|
||
/>
|
||
|
||
<Step>Update the import paths to match your project setup.</Step>
|
||
|
||
</Steps>
|
||
|
||
</TabsContent>
|
||
|
||
</CodeTabs>
|
||
|
||
## Usage
|
||
|
||
```tsx
|
||
import { Message } from "@/components/ui/message"
|
||
import {
|
||
MessageScroller,
|
||
MessageScrollerButton,
|
||
MessageScrollerContent,
|
||
MessageScrollerItem,
|
||
MessageScrollerProvider,
|
||
MessageScrollerViewport,
|
||
} from "@/components/ui/message-scroller"
|
||
```
|
||
|
||
```tsx
|
||
<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.
|
||
|
||
```tsx
|
||
<div className="flex h-screen flex-col">
|
||
<MessageScrollerProvider>
|
||
<MessageScroller className="flex-1">{/* transcript */}</MessageScroller>
|
||
</MessageScrollerProvider>
|
||
</div>
|
||
```
|
||
|
||
## Composition
|
||
|
||
```tsx
|
||
<MessageScrollerProvider>
|
||
<MessageScroller>
|
||
<MessageScrollerViewport>
|
||
<MessageScrollerContent>
|
||
<MessageScrollerItem>
|
||
{/* a message, marker, or row */}
|
||
</MessageScrollerItem>
|
||
<MessageScrollerItem />
|
||
<MessageScrollerItem />
|
||
</MessageScrollerContent>
|
||
</MessageScrollerViewport>
|
||
<MessageScrollerButton />
|
||
</MessageScroller>
|
||
</MessageScrollerProvider>
|
||
```
|
||
|
||
- **`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. An item can be a message, marker, typing
|
||
indicator, separator, join/leave event, or "load earlier" row.
|
||
- **`MessageScrollerButton`** — the scroll control. Scrolls to the start or end of the transcript and is inert until there is content in its direction.
|
||
|
||
## Core Concepts
|
||
|
||
### 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.
|
||
|
||
An anchor is the row the viewport should treat as the start of that turn. Mark
|
||
that row with `scrollAnchor`. When a new anchor is appended, the viewport moves
|
||
it near the top and keeps a peek of the previous item above it, so the new turn
|
||
does not feel detached from its context.
|
||
|
||
```tsx
|
||
// This tells the scroller to anchor the user's message for the next turn.
|
||
<MessageScrollerItem
|
||
messageId={message.id}
|
||
scrollAnchor={message.role === "user"}
|
||
/>
|
||
```
|
||
|
||
Scroll anchors are not tied to message role. You can turn any row into an anchor:
|
||
a user message, a system marker, a handoff event, or anything else that starts a
|
||
meaningful turn. `MessageScroller` only needs to know which row should anchor the
|
||
viewport.
|
||
|
||
In the following example, the user's message is anchored. When you send a new message, the viewport anchors it near the top and appends the assistant reply below it. Toggle the anchor to the assistant's message to see the difference.
|
||
|
||
<ComponentPreview
|
||
styleName="base-rhea"
|
||
name="message-scroller-anchoring"
|
||
className="rounded-[34px] sm:rounded-4xl"
|
||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||
/>
|
||
|
||
### 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.
|
||
|
||
Because anchoring is role-independent, you can anchor a marker just as easily as
|
||
a message.
|
||
|
||
```tsx
|
||
<MessageScrollerItem messageId="marcus-joined" scrollAnchor>
|
||
<Marker variant="separator">
|
||
<MarkerContent>Marcus joined the chat</MarkerContent>
|
||
</Marker>
|
||
</MessageScrollerItem>
|
||
```
|
||
|
||
<ComponentPreview
|
||
styleName="base-rhea"
|
||
name="message-scroller-group-chat"
|
||
className="rounded-[34px] sm:rounded-4xl"
|
||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||
/>
|
||
|
||
### 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.
|
||
|
||
```tsx
|
||
// Keep 64px of the previous turn visible above the newly anchored row.
|
||
<MessageScrollerProvider scrollPreviousItemPeek={64}>
|
||
<MessageScroller>{/* anchored turns */}</MessageScroller>
|
||
</MessageScrollerProvider>
|
||
```
|
||
|
||
Adjust the peek amount in the example below to see how it affects the conversation.
|
||
|
||
<ComponentPreview
|
||
styleName="base-rhea"
|
||
name="message-scroller-previous-context"
|
||
className="rounded-[34px] sm:rounded-4xl"
|
||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||
/>
|
||
|
||
### 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.
|
||
|
||
`autoScroll` composes with turn anchoring. When a new turn anchors near the
|
||
top, the view stays put while the reply streams into the room below it. Once
|
||
the reply fills the viewport, the reader is back at the live edge and
|
||
follow-output takes over from the anchor.
|
||
|
||
```tsx
|
||
<MessageScrollerProvider autoScroll>
|
||
<MessageScroller>{/* streamed turns */}</MessageScroller>
|
||
</MessageScrollerProvider>
|
||
```
|
||
|
||
<ComponentPreview
|
||
styleName="base-rhea"
|
||
name="message-scroller-streaming"
|
||
className="rounded-[34px] sm:rounded-4xl"
|
||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||
/>
|
||
|
||
Calling `scrollToEnd`, or pressing `MessageScrollerButton`, re-engages
|
||
follow-output when `autoScroll` is enabled, so a reader who scrolled away can
|
||
return to the live edge and keep following. The root and viewport expose
|
||
`data-autoscrolling` while that programmatic scroll to the latest message runs,
|
||
so you can conditionally apply styles during the transition.
|
||
|
||
### 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.
|
||
|
||
That gives the reader an immediate place in the thread. They can see what they
|
||
asked, where the answer starts, and continue from there without reconstructing
|
||
the conversation from the bottom edge.
|
||
|
||
```tsx
|
||
<MessageScrollerProvider defaultScrollPosition="last-anchor">
|
||
<MessageScroller>{/* transcript */}</MessageScroller>
|
||
</MessageScrollerProvider>
|
||
```
|
||
|
||
<ComponentPreview
|
||
styleName="base-rhea"
|
||
name="message-scroller-opening-position"
|
||
className="rounded-[34px] sm:rounded-4xl"
|
||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||
hideCode
|
||
/>
|
||
|
||
`"last-anchor"` is keyed on `scrollAnchor`, not message role. If no anchor
|
||
exists, or the last anchored turn already fits in the viewport, it falls back to
|
||
`"end"`.
|
||
|
||
Use `"start"` when you want to resume at the beginning of a conversation, or
|
||
`"end"` when the absolute latest message is the right place to land.
|
||
|
||
### 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.
|
||
|
||
This is enabled by default through `preserveScrollOnPrepend`.
|
||
|
||
<ComponentPreview
|
||
styleName="base-rhea"
|
||
name="message-scroller-load-history"
|
||
className="rounded-[34px] sm:rounded-4xl"
|
||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||
/>
|
||
|
||
Use stable `messageId` values for message rows. That gives the scroller a
|
||
specific row to preserve instead of guessing from whichever pixel happens to sit
|
||
at the viewport edge.
|
||
|
||
### Animating New Messages
|
||
|
||
`MessageScrollerItem` can be animated directly. Create a motion version of the
|
||
item, keep `messageId` and `scrollAnchor` on it, and use transform and opacity
|
||
for the entrance.
|
||
|
||
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.
|
||
|
||
```tsx
|
||
const MotionMessageScrollerItem = motion.create(MessageScrollerItem)
|
||
```
|
||
|
||
<ComponentPreview
|
||
styleName="base-rhea"
|
||
name="message-scroller-animation"
|
||
className="rounded-[34px] sm:rounded-4xl"
|
||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||
/>
|
||
|
||
Avoid animating height, margin, or padding for row entrances; those changes can
|
||
fight the scroller's positioning work. If the reader prefers reduced motion,
|
||
skip the entrance animation and keep the scroll behavior the same.
|
||
|
||
### 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.
|
||
|
||
```tsx
|
||
import { useMessageScroller } from "@/components/ui/message-scroller"
|
||
```
|
||
|
||
```tsx
|
||
const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller()
|
||
```
|
||
|
||
<ComponentPreview
|
||
styleName="base-rhea"
|
||
name="message-scroller-commands"
|
||
className="rounded-[34px] sm:rounded-4xl"
|
||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||
hideCode
|
||
/>
|
||
|
||
`scrollToMessage` targets the `messageId` on `MessageScrollerItem`, so rows that
|
||
need to be addressable should have stable ids. `scrollToMessage` returns `false`
|
||
when the target is not mounted and cannot be queued.
|
||
|
||
`scrollToMessage` can queue a target before items exist, which covers
|
||
client-resolved permalinks while the transcript mounts. After rows have mounted,
|
||
a missing id returns `false` instead of starting a guessed retry loop. A `true`
|
||
result means the scroll ran or was queued, not that the row is already in view.
|
||
|
||
### 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.
|
||
|
||
```tsx
|
||
import { useMessageScrollerVisibility } from "@/components/ui/message-scroller"
|
||
```
|
||
|
||
```tsx
|
||
const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()
|
||
```
|
||
|
||
<ComponentPreview
|
||
styleName="base-rhea"
|
||
name="message-scroller-visibility"
|
||
className="rounded-[34px] sm:rounded-4xl"
|
||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||
hideCode
|
||
/>
|
||
|
||
`currentAnchorId` answers "where am I" by reporting the current anchored turn,
|
||
and it stays set after that anchor scrolls above the viewport. `visibleMessageIds`
|
||
answers "what is on screen", in document order.
|
||
|
||
Visibility is pay-for-what-you-use. Tracking only runs while something
|
||
subscribes to `useMessageScrollerVisibility`, and rows need a `messageId` to
|
||
participate.
|
||
|
||
### 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.
|
||
|
||
```tsx
|
||
import { useMessageScrollerScrollable } from "@/components/ui/message-scroller"
|
||
```
|
||
|
||
```tsx
|
||
const { start, end } = useMessageScrollerScrollable()
|
||
```
|
||
|
||
<ComponentPreview
|
||
styleName="base-rhea"
|
||
name="message-scroller-scrollable"
|
||
className="rounded-[34px] sm:rounded-4xl"
|
||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||
/>
|
||
|
||
## Performance
|
||
|
||
`MessageScroller` is benchmarked against large transcripts with markdown and
|
||
composed message rows.
|
||
|
||
Our performance goal for `MessageScroller` is to keep the scroll hot path outside of React state: no React rerenders for
|
||
transcript rows, no forced layout on every scroll, and as little off-screen paint
|
||
work as the browser can avoid.
|
||
|
||
Scroll position, anchoring, and follow-output are tracked imperatively and mirrored onto the root and viewport through `data-*` attributes, so scrolling and streaming do not rerender transcript rows.
|
||
|
||
The styled `MessageScrollerItem` also ships with `content-visibility: auto` and
|
||
`contain-intrinsic-size`. Rows stay in the DOM for selection, copy,
|
||
find-in-page, SSR, and assistive tech, but the browser can skip rendering work
|
||
for rows far outside the viewport.
|
||
|
||
Visibility tracking is pay-for-what-you-use. A jump menu or active
|
||
turn indicator costs nothing until something subscribes to
|
||
`useMessageScrollerVisibility`.
|
||
|
||
This is comfortable for the expected range of a chat transcript: hundreds to low
|
||
thousands of turns, including messages with markdown and composed components.
|
||
|
||
## Virtualization
|
||
|
||
Virtualization is intentionally left outside the primitive. `MessageScroller`
|
||
renders real DOM rows and stays fast well into the thousands of turns (see
|
||
[Performance](#performance)), so most transcripts never need it.
|
||
|
||
When a transcript is large enough to need virtualization, use
|
||
`MessageScrollerViewport` as the scroll element and let the virtualizer own the
|
||
rows.
|
||
|
||
```tsx showLineNumbers
|
||
import * as React from "react"
|
||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||
|
||
function VirtualizedTranscript({
|
||
messages,
|
||
}: {
|
||
messages: Array<{ id: string; content: React.ReactNode }>
|
||
}) {
|
||
const viewportRef = React.useRef<HTMLDivElement>(null)
|
||
|
||
const virtualizer = useVirtualizer({
|
||
count: messages.length,
|
||
getScrollElement: () => viewportRef.current,
|
||
estimateSize: () => 86,
|
||
getItemKey: (index) => messages[index]?.id ?? index,
|
||
overscan: 8,
|
||
})
|
||
|
||
return (
|
||
<MessageScrollerProvider>
|
||
<MessageScroller>
|
||
<MessageScrollerViewport ref={viewportRef}>
|
||
<MessageScrollerContent className="block min-h-full">
|
||
<div
|
||
className="relative w-full"
|
||
style={{ height: virtualizer.getTotalSize() }}
|
||
>
|
||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||
const message = messages[virtualItem.index]
|
||
|
||
if (!message) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<div
|
||
key={virtualItem.key}
|
||
ref={virtualizer.measureElement}
|
||
data-index={virtualItem.index}
|
||
className="absolute start-0 top-0 w-full"
|
||
style={{
|
||
transform: `translateY(${virtualItem.start}px)`,
|
||
}}
|
||
>
|
||
<Message>{message.content}</Message>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</MessageScrollerContent>
|
||
</MessageScrollerViewport>
|
||
<MessageScrollerButton />
|
||
</MessageScroller>
|
||
</MessageScrollerProvider>
|
||
)
|
||
}
|
||
```
|
||
|
||
## Accessibility
|
||
|
||
`MessageScroller` keeps the scroll container keyboard reachable and the
|
||
transcript announceable without forcing a specific message UI.
|
||
|
||
`MessageScrollerViewport` is a labelled, keyboard-focusable scroll region by
|
||
default. It uses `role="region"`, `aria-label="Messages"`, and `tabIndex={0}`,
|
||
so keyboard users can focus the transcript and scroll it directly.
|
||
|
||
`MessageScrollerContent` marks the transcript as a live region with
|
||
`role="log"` and `aria-relevant="additions"`. New rows can be announced, but
|
||
streamed text mutations do not have to be announced token by token.
|
||
|
||
```tsx
|
||
<MessageScrollerContent aria-busy={status === "streaming"}>
|
||
{/* messages */}
|
||
</MessageScrollerContent>
|
||
```
|
||
|
||
Pass `aria-busy` while a turn streams if announcements should wait for the
|
||
completed message row.
|
||
|
||
`MessageScrollerButton` renders a real button. When there is nothing to scroll
|
||
toward, it sets `inert`, uses `tabIndex={-1}`, and exposes `data-active="false"`
|
||
so inactive scroll controls do not create extra focus stops.
|
||
|
||
## Unstyled
|
||
|
||
The behavior in `MessageScroller` comes from the `@shadcn/react` package. To use
|
||
it directly with your own markup and styles, see
|
||
[Message Scroller](/docs/react/message-scroller) under @shadcn/react.
|
||
|
||
## API Reference
|
||
|
||
The props, data attributes, and hooks for every part are documented on the
|
||
[@shadcn/react Message Scroller](/docs/react/message-scroller#api-reference) page.
|
||
They are identical for the styled component and the unstyled parts.
|