Skip to content

Chat Layout

Full-page chat shell: a scrollable message area with a composer docked to the bottom over a frosted-glass blur, streaming auto-scroll with lock/unlock, and a scroll-to-bottom button that doubles as a new-message indicator. Density adapts to the layout width.

Hey! Can you summarize the launch plan?
Of course. The launch has three phases: a private beta in August, an open beta in September, and general availability in October.
Who owns the open beta milestone?
The platform team owns it, with marketing handling the announcement. The exit criteria are 99.9% uptime over two weeks and fewer than five open P1 bugs.
2:31 PMRead
function DemoConversation() {
return (
<ChatMessageList>
<ChatSystemMessage variant="divider">Today</ChatSystemMessage>
{CONVERSATION.map(message => (
<ChatMessage
avatar={
<Avatar
name={message.sender === 'user' ? 'Cindy Park' : 'Navi'}
size="small"
/>
}
key={message.id}
sender={message.sender}>
<ChatMessageBubble
metadata={
message.id === CONVERSATION.length ? (
<ChatMessageMetadata status="read" timestamp="2:31 PM" />
) : undefined
}>
{message.text}
</ChatMessageBubble>
</ChatMessage>
))}
</ChatMessageList>
);
}
<div className={frameStyle}>
<ChatLayout composer={<ChatComposer onSubmit={() => {}} />}>
<DemoConversation />
</ChatLayout>
</div>
Tell me how the auto-scroll works.
function StreamingPlaygroundDemo() {
const [messages, setMessages] = useState<DemoMessage[]>([
{id: 1, sender: 'user', text: 'Tell me how the auto-scroll works.'},
]);
const [isStreaming, setIsStreaming] = useState(false);
const wordsRef = useRef<string[]>([]);
const nextIdRef = useRef(2);
useEffect(() => {
if (!isStreaming) {
return;
}
const interval = setInterval(() => {
const word = wordsRef.current.shift();
if (word == null) {
setIsStreaming(false);
return;
}
setMessages(current => {
const last = current[current.length - 1];
if (last.sender !== 'assistant') {
return [
...current,
{id: nextIdRef.current++, sender: 'assistant', text: word},
];
}
return [
...current.slice(0, -1),
{...last, text: `${last.text} ${word}`},
];
});
}, 120);
return () => clearInterval(interval);
}, [isStreaming]);
return (
<div className={frameStyle}>
<ChatLayout
composer={
<ChatComposer
isStopShown={isStreaming}
onStop={() => setIsStreaming(false)}
onSubmit={value => {
setMessages(current => [
...current,
{id: nextIdRef.current++, sender: 'user', text: value},
]);
wordsRef.current = STREAM_TEXT.repeat(2).split(' ');
setIsStreaming(true);
}}
placeholder="Send a message to start streaming…"
/>
}>
<ChatMessageList>
{messages.map(message => (
<ChatMessage key={message.id} sender={message.sender}>
<ChatMessageBubble>{message.text}</ChatMessageBubble>
</ChatMessage>
))}
</ChatMessageList>
</ChatLayout>
</div>
);
}
<StreamingPlaygroundDemo />

No messages yet

Ask anything to get going.
<div className={frameStyle}>
<ChatLayout
composer={<ChatComposer onSubmit={() => {}} />}
emptyState={
<EmptyState
description="Ask anything to get going."
title="No messages yet"
/>
}
/>
</div>
Hey! Can you summarize the launch plan?
Of course. The launch has three phases: a private beta in August, an open beta in September, and general availability in October.
Who owns the open beta milestone?
The platform team owns it, with marketing handling the announcement. The exit criteria are 99.9% uptime over two weeks and fewer than five open P1 bugs.
2:31 PMRead
Hey! Can you summarize the launch plan?
Of course. The launch has three phases: a private beta in August, an open beta in September, and general availability in October.
Who owns the open beta milestone?
The platform team owns it, with marketing handling the announcement. The exit criteria are 99.9% uptime over two weeks and fewer than five open P1 bugs.
2:31 PMRead
Hey! Can you summarize the launch plan?
Of course. The launch has three phases: a private beta in August, an open beta in September, and general availability in October.
Who owns the open beta milestone?
The platform team owns it, with marketing handling the announcement. The exit criteria are 99.9% uptime over two weeks and fewer than five open P1 bugs.
2:31 PMRead
function DemoConversation() {
return (
<ChatMessageList>
<ChatSystemMessage variant="divider">Today</ChatSystemMessage>
{CONVERSATION.map(message => (
<ChatMessage
avatar={
<Avatar
name={message.sender === 'user' ? 'Cindy Park' : 'Navi'}
size="small"
/>
}
key={message.id}
sender={message.sender}>
<ChatMessageBubble
metadata={
message.id === CONVERSATION.length ? (
<ChatMessageMetadata status="read" timestamp="2:31 PM" />
) : undefined
}>
{message.text}
</ChatMessageBubble>
</ChatMessage>
))}
</ChatMessageList>
);
}
<div className={css({display: 'flex', flexDirection: 'column', gap: '6'})}>
{[400, 700, 1000].map(width => (
<div className={frameStyle} key={width} style={{maxWidth: width}}>
<ChatLayout composer={<ChatComposer onSubmit={() => {}} />}>
<DemoConversation />
</ChatLayout>
</div>
))}
</div>
Navi
Grouped bubbles share one avatar and name
and tighten their sender-side corners
so a run of messages reads as one turn.
2:30 PM
Nice.
Very tidy!
<div className={frameStyle}>
<ChatLayout composer={<ChatComposer onSubmit={() => {}} />}>
<ChatMessageList gap={1}>
<ChatMessage avatar={<Avatar name="Navi" />} sender="assistant">
<ChatMessageBubble group="first" name="Navi">
Grouped bubbles share one avatar and name
</ChatMessageBubble>
<ChatMessageBubble group="middle">
and tighten their sender-side corners
</ChatMessageBubble>
<ChatMessageBubble
group="last"
metadata={<ChatMessageMetadata timestamp="2:30 PM" />}>
so a run of messages reads as one turn.
</ChatMessageBubble>
</ChatMessage>
<ChatMessage avatar={<Avatar name="Cindy Park" />} sender="user">
<ChatMessageBubble group="first">Nice.</ChatMessageBubble>
<ChatMessageBubble group="last">Very tidy!</ChatMessageBubble>
</ChatMessage>
</ChatMessageList>
</ChatLayout>
</div>

Full-page chat shell: a scrollable message area with a composer docked to the bottom over a frosted-glass blur, streaming auto-scroll with lock/unlock, and a scroll-to-bottom button that doubles as a new-message indicator. Density adapts to the layout width.

PropTypeDefaultDescription
childrenReactNodeMessage content — typically a ChatMessageList. Flows naturally and scrolls behind the composer dock.
classNamestringAdditional CSS class names applied to the root element.
composer*ReactNodeComposer element docked to the bottom over a frosted-glass layer. Typically a ChatComposer.
data-testidstringTest ID applied to the root element.
emptyStateReactNodeContent shown when children is empty.
refRef<HTMLDivElement>Ref forwarded to the root element.
scrollButtonReactNodeScroll-to-bottom button rendered above the composer. Defaults to a ChatScrollButton wired to the layout's scroll state; pass a custom node to replace it or null to hide it.
scrollRefRefObject<HTMLElement | null>External scroll container. When provided, auto-scroll targets this element and the dock is fixed to the viewport instead of the layout root scrolling.
styleCSSPropertiesInline styles applied to the root element.
aria-describedbyAriaAttributes['aria-describedby']Identifies the element(s) that describe the region.
aria-labelAriaAttributes['aria-label']Accessible label for the region.
aria-labelledbyAriaAttributes['aria-labelledby']Identifies the element(s) that label the region.
idstringHTML id attribute applied to the root element.

Presentational container for chat messages: a `role="log"` flex column with density-based spacing, a bottom-aligning spacer, and optional load-older-messages support. Auto-scroll is owned by ChatLayout.

PropTypeDefaultDescription
aria-liveAriaAttributes['aria-live']'polite'How assistive technologies announce messages appended to the log. Set to off for a static transcript that should not be announced.
childrenReactNodeMessage elements — typically ChatMessage components, optionally mixed with ChatSystemMessage separators.
classNamestringAdditional CSS class names applied to the root element.
data-testidstringTest ID applied to the root element.
density"compact" | "balanced" | "spacious"'balanced'Visual density; flows to child messages via context. Defaults to the surrounding ChatLayout density.
emptyStateReactNodeContent shown when the list has no messages.
gap0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10Gap between top-level message rows, on the spacing scale. Defaults to the selected density's gap; override when row spacing should be tuned separately from density.
refRef<HTMLDivElement>Ref forwarded to the root element.
scrollToTopAction() => Promise<void>Async action invoked when the user scrolls to the top of the list. Use for loading older messages; a spinner is shown at the top while pending.
styleCSSPropertiesInline styles applied to the root element.
aria-describedbyAriaAttributes['aria-describedby']Identifies the element(s) that describe the region.
aria-labelAriaAttributes['aria-label']Accessible label for the region.
aria-labelledbyAriaAttributes['aria-labelledby']Identifies the element(s) that label the region.
idstringHTML id attribute applied to the root element.

Floating scroll-to-bottom pill shown above the composer in a ChatLayout. Expands to show a label such as "New messages" when one is provided.

PropTypeDefaultDescription
classNamestringAdditional CSS class names applied to the root element.
data-testidstringTest ID applied to the root element.
isVisible*booleanWhether the button is visible. The pill fades and collapses while hidden so it can transition in place.
labelstringLabel that expands the pill (e.g. "New messages"). When omitted, only the chevron icon is shown.
onClick*() => voidCalled when the button is clicked.
refRef<HTMLDivElement>Ref forwarded to the root element.
styleCSSPropertiesInline styles applied to the root element.
formstringHTML form attribute associating the button with a <form> by ID.
idstringHTML id attribute applied to the root element.
aria-controlsstringIdentifies the element(s) whose contents are controlled by the button.
aria-describedbystringIdentifies the element(s) that describe the button.
aria-detailsstringIdentifies the element that provides a detailed description.
aria-expandedbooleanIndicates whether a controlled element is expanded or collapsed.
aria-haspopupboolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog'Indicates the button opens an interactive popup element.
aria-keyshortcutsstringKeyboard shortcuts that activate or focus the button.
aria-labelledbystringIdentifies the element(s) that label the button.
onFocusFocusEventHandler<HTMLElement>Focus event handler for the root element.
onKeyDown(event: KeyboardEvent<HTMLElement>) => voidKeyboard event handler for the root element.