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
childrenReactNode—Message content — typically a ChatMessageList. Flows naturally and scrolls behind the composer dock.
classNamestring—Additional CSS class names applied to the root element.
composer*ReactNode—Composer element docked to the bottom over a frosted-glass layer. Typically a ChatComposer.
data-testidstring—Test ID applied to the root element.
emptyStateReactNode—Content shown when children is empty.
refRef<HTMLDivElement>—Ref forwarded to the root element.
scrollButtonReactNode—Scroll-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.
styleCSSProperties—Inline 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.
idstring—HTML 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.
childrenReactNode—Message elements — typically ChatMessage components, optionally mixed with ChatSystemMessage separators.
classNamestring—Additional CSS class names applied to the root element.
data-testidstring—Test 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.
emptyStateReactNode—Content shown when the list has no messages.
gap0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10—Gap 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.
styleCSSProperties—Inline 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.
idstring—HTML 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
classNamestring—Additional CSS class names applied to the root element.
data-testidstring—Test ID applied to the root element.
isVisible*boolean—Whether the button is visible. The pill fades and collapses while hidden so it can transition in place.
labelstring—Label that expands the pill (e.g. "New messages"). When omitted, only the chevron icon is shown.
onClick*() => void—Called when the button is clicked.
refRef<HTMLDivElement>—Ref forwarded to the root element.
styleCSSProperties—Inline styles applied to the root element.
formstring—HTML form attribute associating the button with a <form> by ID.
idstring—HTML id attribute applied to the root element.
aria-controlsstring—Identifies the element(s) whose contents are controlled by the button.
aria-describedbystring—Identifies the element(s) that describe the button.
aria-detailsstring—Identifies the element that provides a detailed description.
aria-expandedboolean—Indicates whether a controlled element is expanded or collapsed.
aria-haspopupboolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog'—Indicates the button opens an interactive popup element.
aria-keyshortcutsstring—Keyboard shortcuts that activate or focus the button.
aria-labelledbystring—Identifies the element(s) that label the button.
onFocusFocusEventHandler<HTMLElement>—Focus event handler for the root element.
onKeyDown(event: KeyboardEvent<HTMLElement>) => void—Keyboard event handler for the root element.
onPointerEnterPointerEventHandler<HTMLElement>—Pointer enter event handler for the root element.