mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-03 01:18:38 +00:00
* feat(@shadcn/react): add message-scroller package Add the @shadcn/react headless primitives package with MessageScroller scroll anchoring, streaming follow, history prepend, and jump-to-message behavior. Includes geometry helpers, use-render utility, and unit, browser, and perf tests. * feat(registry): add chat components Add MessageScroller, Message, Bubble, Attachment, and Marker registry sources for base and radix, style variants, preview-03 chat blocks, and registry index wiring. * feat(v4): integrate chat components into docs site Wire chat components into the v4 app with docs routes, example preview pages, message part renderers, markdown support, registry build updates, and supporting lib utilities. * feat(examples): add chat component demos Add base and radix example demos for MessageScroller, Message, Bubble, Attachment, Marker, scroll-fade, and shimmer. * docs: add chat component documentation Add component and utility docs for the chat component set, update docs navigation, and add the June 2026 chat components changelog entry. * chore: regenerate registry JSON output Rebuild public registry artifacts for all style variants with the new chat components. * chore(release): add @shadcn/react publish and CI pipeline Add Changesets prerelease workflow, browser test job, RELEASING docs, and monorepo wiring for publishing @shadcn/react independently from the shadcn CLI. * docs: fix display of component preview on mobile * fix * fix * docs: add message scroller docs * style: format * fix
587 lines
22 KiB
Plaintext
587 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.
|
||
|
||
```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.
|