ChatMessageListexperimental
A scrollable conversation log that follows streaming content while the reader is at the bottom, and lets them scroll up through history without being pulled back down. Screen readers hear the generation start and the settled answer once, not every token.
Playground
Press Append while scrolled to the bottom: the list follows the new message. Scroll up first and append: following pauses (the badge flips to paused) and a jump-back pill shows how many messages arrived. Click the pill to resume. The live playground shows streaming, tool calls, and citations together.
Question 1: how does the follow-scroll behave?
Answer 1: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.
Question 2: how does the follow-scroll behave?
Answer 2: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.
Question 3: how does the follow-scroll behave?
Answer 3: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.
Question 4: how does the follow-scroll behave?
Answer 4: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.
Question 5: how does the follow-scroll behave?
Answer 5: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.
Question 6: how does the follow-scroll behave?
Answer 6: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.
<script lang="ts">
import { ChatMessageList } from '@urbicon-ui/blocks';
let messages = $state([
{
id: 'seed-1-q',
role: 'user',
parts: [{ type: 'text', text: 'Question 1: how does the follow-scroll behave?' }],
status: 'complete'
},
{
id: 'seed-1-a',
role: 'assistant',
parts: [
{ type: 'text', text: 'Answer 1: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.' }
],
status: 'complete'
},
{
id: 'seed-2-q',
role: 'user',
parts: [{ type: 'text', text: 'Question 2: how does the follow-scroll behave?' }],
status: 'complete'
},
{
id: 'seed-2-a',
role: 'assistant',
parts: [
{ type: 'text', text: 'Answer 2: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.' }
],
status: 'complete'
},
{
id: 'seed-3-q',
role: 'user',
parts: [{ type: 'text', text: 'Question 3: how does the follow-scroll behave?' }],
status: 'complete'
},
{
id: 'seed-3-a',
role: 'assistant',
parts: [
{ type: 'text', text: 'Answer 3: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.' }
],
status: 'complete'
},
{
id: 'seed-4-q',
role: 'user',
parts: [{ type: 'text', text: 'Question 4: how does the follow-scroll behave?' }],
status: 'complete'
},
{
id: 'seed-4-a',
role: 'assistant',
parts: [
{ type: 'text', text: 'Answer 4: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.' }
],
status: 'complete'
},
{
id: 'seed-5-q',
role: 'user',
parts: [{ type: 'text', text: 'Question 5: how does the follow-scroll behave?' }],
status: 'complete'
},
{
id: 'seed-5-a',
role: 'assistant',
parts: [
{ type: 'text', text: 'Answer 5: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.' }
],
status: 'complete'
},
{
id: 'seed-6-q',
role: 'user',
parts: [{ type: 'text', text: 'Question 6: how does the follow-scroll behave?' }],
status: 'complete'
},
{
id: 'seed-6-a',
role: 'assistant',
parts: [
{ type: 'text', text: 'Answer 6: while you sit at the bottom the list follows new content. Scroll up and it lets go — a jump-back pill appears with the count of what you missed.' }
],
status: 'complete'
}
]);
</script>
<ChatMessageList
{messages}
layout="bubble"
/>01 Examples
Streaming append
<script lang="ts">
import { ChatMessageList, type ChatMessageData } from '@urbicon-ui/blocks';
let messages = $state<ChatMessageData[]>(history);
let following = $state(true);
function streamChunk(id: string, chunk: string) {
messages = messages.map((m) =>
m.id === id
? { ...m, parts: [{ type: 'text', text: (m.parts[0]?.text ?? '') + chunk }] }
: m
);
}
</script>
<ChatMessageList
{messages}
onStickChange={(stuck) => (following = stuck)}
/>Load older history (prepend anchor)
<script lang="ts">
import { ChatMessageList, type ChatMessageData } from '@urbicon-ui/blocks';
let messages = $state<ChatMessageData[]>(recent);
let loading = $state(false);
async function loadOlder() {
if (loading) return;
loading = true;
const older = await fetchOlderPage(); // resolves to ChatMessageData[]
messages = [...older, ...messages]; // prepend — the anchor holds your place
loading = false;
}
</script>
<div class="flex flex-col">
<button onclick={loadOlder} disabled={loading}>Load older messages</button>
<ChatMessageList {messages} />
</div>Custom per-message rendering
<ChatMessageList {messages}>
{#snippet message({ message, isLast })}
{#if message.role === 'system'}
<div class="text-center text-xs text-text-tertiary">{message.parts[0]?.text}</div>
{:else}
<ChatMessage
{message}
onRegenerate={isLast && message.role === 'assistant' ? () => regenerate(message.id) : undefined}
/>
{/if}
{/snippet}
</ChatMessageList>02 Scroll engine
The list adjusts the scroll position itself rather than through CSS overflow-anchor, which Safari does not support. Four
behaviours follow from that:
- Follow while at the bottom. New content keeps the viewport pinned to the latest message.
- Upward scroll breaks the follow. When you scroll away from the bottom, the list stops following and shows a floating jump-back pill with the count of new messages.
- Proximity re-stick. Scroll back near the bottom (or click the pill) and
following resumes.
onStickChangefires on every flip. - Prepend anchoring. When older messages are added to the front, the current message stays in place instead of jumping.
Note: rest attributes (including a raw onscroll) land on
the non-scrolling root, so observe follow-state through onStickChange rather than a scroll listener.
03 Accessibility
Why the log is aria-live="off"
The messages render inside a role="log" region, but
its live channel is off. A streaming answer changes the DOM dozens of times
a second; a polite or assertive log would announce every token, so the log stays silent.
The separate status region
Announcements come instead from a visually hidden role="status" region that carries only the meaningful
transitions: generatingLabel once when an assistant
message starts streaming, and the settled answer once when it completes (or errorLabel / abortedLabel for a failed stream). Screen-reader users
hear "generating…", then the final answer, rather than each token.
Scrollable region is focusable
The viewport is a labelled role="region" (listLabel) with tabindex="0", so keyboard users can focus the
conversation and scroll it with the arrow / Page keys. Its focus ring uses focus-visible: (keyboard-only).
The jump-back button
The floating pill is a real <button> whose aria-label carries the pending count (newMessagesLabel) or falls back to scrollToBottomLabel when nothing is pending, so its purpose
is announced rather than implied by an icon.
04 API Reference
23 propsProp | Type | Default | Description | |
|---|---|---|---|---|
messages required | ChatMessageData[] | — | The conversation, oldest first. The component never mutates it. Note:
rest attributes (incl. a raw onscroll) land on the non-scrolling root —
observe follow-state via onStickChange instead. | |
abortedLabel | string | — | Screen-reader announcement when a stream ends in aborted without text. | |
class | string | — | Extra classes merged onto the root element. | |
density | ChatMessageProps['density'] | — | Message density handed to every default ChatMessage. | |
empty | Snippet | — | Empty-state content. Default: an EmptyState with emptyTitle/emptyDescription. | |
emptyDescription | string | — | Empty-state description. | |
emptyTitle | string | — | Empty-state heading. | |
errorLabel | string | — | Screen-reader announcement when a stream ends in error without text. | |
generatingLabel | string | — | Screen-reader announcement when an assistant message starts streaming. | |
layout | ChatMessageProps['layout'] | — | Message layout handed to every default ChatMessage. | |
listLabel | string | — | Accessible name of the scrollable conversation region. | |
message | Snippet<[ChatMessageListItemContext]> | — | Per-message renderer override. Default: <ChatMessage> wired with the props below. | |
newMessagesLabel | string | — | Label suffix of the jump button while new messages are pending (prefixed with the count). | |
onRegenerate | (message: ChatMessageData) => void | — | Regenerate handler — wired only to the last message when it is an assistant message. | |
onRetry | (message: ChatMessageData) => void | — | Retry handler for messages in error/aborted status. | |
onStickChange | (stuck: boolean) => void | — | Fires when the list starts (true) or stops (false) following new content. | |
partRenderers | ChatMessageProps['partRenderers'] | — | Part-renderer overrides handed to every default ChatMessage. | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ ChatMessageList: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette. | |
scrollToBottomLabel | string | — | Accessible label of the jump button when there are no new messages. | |
slotClasses | Partial<Record<ChatMessageListSlots, string>> | — | Per-slot class overrides. Slots: root | viewport | content | empty | newButton | |
unstyled | boolean | — | Remove all default tv classes, from this component and the ones it renders (the messages, the empty state, the jump badge). | |
urlPolicy | MarkdownUrlPolicy | — | URL policy handed to every default ChatMessage (links, citations, attachments). | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children') |
05 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
ChatMessageListItemContext | interface | helper | 0 | Render context handed to the per-message override snippet. | |
ChatMessageListProps | interface | props | 0 | — | |
ChatMessageProps | interface | props | 0 | — | |
ChatMessageData | interface | helper | 1 | One message in a conversation. Named ChatMessageData because the value
export ChatMessage is the component that renders it. | |
MarkdownUrlPolicy | interface | helper | 1 | — | |
ChatMessageListSlots | type | variant | 0 | Slot names derived from the tv() config — single source of truth for slotClasses. | |
ChatMessageVariants | type | variant | 0 | — | |
ChatRole | type | helper | 0 | Author of a chat message. | |
ChatPartRenderers | type | helper | 0 | Per-part snippet overrides keyed by part type. When a renderer exists for a
part's type it replaces the built-in rendering for that part (one level up
from StreamingMarkdown's node renderers) — this is how P3 swaps in
ToolCallCard / ReasoningDisclosure and P4 adds A2UIView without touching this
component. source is intentionally not overridable here: sources are
collected into the citation footer, not rendered inline. | |
ChatMessageSlots | type | variant | 0 | Slot names derived from the tv() config — single source of truth for slotClasses. | |
ChatMessagePart | type | helper | 0 | One renderable segment of a message. Mirrors the shape of modern model/tool transcripts: interleaved text, reasoning, tool calls, sources and attachments — rendered in order by the ChatMessage component. | |
ChatMessageStatus | type | helper | 0 | Lifecycle of a message. streaming drives the live-rendering affordances
(markdown tail repair, cursor, deferred screen-reader announcement);
error / aborted switch the message to its failure presentation.
A message without a status counts as complete. | |
SlotNames | type | helper | 0 | Extracts the slot-name union from a slotted tv() config function — the
companion to VariantProps. The slot-mode overload returns
(props?) => { [K in keyof S]: SlotFn }, so keyof ReturnType<T> is exactly
the set of slot names a component declares in tv({ slots: … }).
Use it to type a component's slotClasses prop from the single source of
truth (its *.variants.ts) instead of hand-maintaining a parallel union
that silently drifts when a slot is added or renamed: | |
VariantProps | type | helper | 0 | — | |
CitationSource | interface | helper | 0 | A cited source surfaced behind a [id] citation marker. id keys the
source to its marker; title is always shown, url / snippet are optional
and only render when present (and, for url, when the URL policy allows it). |
06 Installation
Import
import { ChatMessageList } from '@urbicon-ui/blocks';
import type { ChatMessageData, ChatMessageListItemContext } from '@urbicon-ui/blocks';