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.
Examples
Section titled “Examples”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>Streaming Playground
Section titled “Streaming Playground”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 />With Empty State
Section titled “With Empty State”No messages yet
<div className={frameStyle}> <ChatLayout composer={<ChatComposer onSubmit={() => {}} />} emptyState={ <EmptyState description="Ask anything to get going." title="No messages yet" /> } /></div>Densities
Section titled “Densities”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>Grouped Bubbles
Section titled “Grouped Bubbles”<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>ChatLayout
Section titled “ChatLayout”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.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Message content — typically a ChatMessageList. Flows naturally and scrolls behind the composer dock. |
className | string | — | 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-testid | string | — | Test ID applied to the root element. |
emptyState | ReactNode | — | Content shown when children is empty. |
ref | Ref<HTMLDivElement> | — | Ref forwarded to the root element. |
scrollButton | ReactNode | — | 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. |
scrollRef | RefObject<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. |
style | CSSProperties | — | Inline styles applied to the root element. |
aria-describedby | AriaAttributes['aria-describedby'] | — | Identifies the element(s) that describe the region. |
aria-label | AriaAttributes['aria-label'] | — | Accessible label for the region. |
aria-labelledby | AriaAttributes['aria-labelledby'] | — | Identifies the element(s) that label the region. |
id | string | — | HTML id attribute applied to the root element. |
ChatMessageList
Section titled “ChatMessageList”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.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-live | AriaAttributes['aria-live'] | 'polite' | How assistive technologies announce messages appended to the log. Set to off for a static transcript that should not be announced. |
children | ReactNode | — | Message elements — typically ChatMessage components, optionally mixed with ChatSystemMessage separators. |
className | string | — | Additional CSS class names applied to the root element. |
data-testid | string | — | 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. |
emptyState | ReactNode | — | Content shown when the list has no messages. |
gap | 0 | 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. |
ref | Ref<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. |
style | CSSProperties | — | Inline styles applied to the root element. |
aria-describedby | AriaAttributes['aria-describedby'] | — | Identifies the element(s) that describe the region. |
aria-label | AriaAttributes['aria-label'] | — | Accessible label for the region. |
aria-labelledby | AriaAttributes['aria-labelledby'] | — | Identifies the element(s) that label the region. |
id | string | — | HTML id attribute applied to the root element. |
ChatScrollButton
Section titled “ChatScrollButton”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.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Additional CSS class names applied to the root element. |
data-testid | string | — | 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. |
label | string | — | 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. |
ref | Ref<HTMLDivElement> | — | Ref forwarded to the root element. |
style | CSSProperties | — | Inline styles applied to the root element. |
form | string | — | HTML form attribute associating the button with a <form> by ID. |
id | string | — | HTML id attribute applied to the root element. |
aria-controls | string | — | Identifies the element(s) whose contents are controlled by the button. |
aria-describedby | string | — | Identifies the element(s) that describe the button. |
aria-details | string | — | Identifies the element that provides a detailed description. |
aria-expanded | boolean | — | Indicates whether a controlled element is expanded or collapsed. |
aria-haspopup | boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog' | — | Indicates the button opens an interactive popup element. |
aria-keyshortcuts | string | — | Keyboard shortcuts that activate or focus the button. |
aria-labelledby | string | — | Identifies the element(s) that label the button. |
onFocus | FocusEventHandler<HTMLElement> | — | Focus event handler for the root element. |
onKeyDown | (event: KeyboardEvent<HTMLElement>) => void | — | Keyboard event handler for the root element. |