v1.5

Chat Conversation

Scrolling log for AI chat that follows streamed replies and lets go when the reader scrolls up.

Pro
Our preview deploys started failing this morning. Where do I start?
Open the failed deployment and check the build step first. Most preview failures come from a missing environment variable that only exists in production.
The log says DATABASE_URL is undefined.
That confirms it. In your project settings, open Environment Variables and enable DATABASE_URL for the Preview environment, then redeploy the branch.
Should previews use the production database?
No. Point previews at a separate branch database or a seeded staging copy, so a test migration can never touch real customer data.

Description

ChatConversation is the scroll container around a chat thread. The root renders a <div role="log"> that owns the vertical scroll, ChatConversation.Content is the message column inside it, and ChatConversation.ScrollButton is the optional button that brings the reader back to the newest message. It mounts parked at the bottom and follows new content while the reader stays there.

The moment the reader scrolls up (wheel, touch, keyboard, scrollbar), it stops following, so a long streamed answer never drags them away from the paragraph they are reading. Scrolling back to the bottom, or pressing the scroll button, re-attaches it. Use it for assistant panels, support chats and full-page threads, with ChatMessage turns inside.

Don't use it for content that doesn't grow at the end. A settings page, a document or a sidebar list should use Scroll Area or plain overflow. For a list of past threads use Chat List View, and for the messages themselves use Chat Message.

ChatConversation is a Create UI Pro component. Install it with a Pro seat.

Installation

pnpm dlx @create-ui/cli add chat-conversation

Anatomy

<ChatConversation>
  <ChatConversation.Content>
    <ChatMessage />
  </ChatConversation.Content>
  <ChatConversation.ScrollButton />
</ChatConversation>

Usage

import { ChatConversation } from "@/components/ui/chat-conversation"
<ChatConversation aria-label="Support chat" isStreaming={isStreaming}>
  <ChatConversation.Content>{messages}</ChatConversation.Content>
  <ChatConversation.ScrollButton />
</ChatConversation>

Examples

Every example is a Pro preview (marked with a Pro badge).

Without fade

By default the top and bottom edges fade while there is content past them, and each edge stops fading once the viewport reaches it. fade={false} turns the mask off, which suits a conversation sitting on a busy or bordered surface.

Pro
Translate 'the invoice is overdue' to German.
Die Rechnung ist überfällig.
And a polite reminder to pay it?
Wir möchten Sie freundlich daran erinnern, dass die Rechnung noch offen ist. Bitte begleichen Sie den Betrag innerhalb der nächsten sieben Tage.
Make it a bit more formal.
Wir erlauben uns, Sie auf die noch ausstehende Zahlung hinzuweisen, und bitten um Überweisung des offenen Betrags innerhalb von sieben Tagen.

Scroll button

Scroll up and ChatConversation.ScrollButton appears above the bottom edge. It stays mounted while hidden, so it never shifts the layout. Pass tooltip for a hover label and aria-label to describe where it goes.

Pro
Walk me through Stripe webhooks, one step per message.
1. Create the Stripe webhook endpoint in the dashboard and copy its signing secret.
2. Add the secret to your server as STRIPE_WEBHOOK_SECRET.
3. Read the raw request body before any JSON parser touches it.
4. Verify the signature with stripe.webhooks.constructEvent.
5. Switch on event.type and handle checkout.session.completed first.
6. Return a 200 quickly and move slow work to a background job.
7. Store the event id so a retried delivery is processed only once.

Empty state

ChatConversation.Content is a flex column, so flex-1 items-center justify-center centers a first-run message in the viewport. Swap it for the thread once the first message is sent.

Pro

No messages yet

Ask about code review, testing or release notes.

Streaming

Pass isStreaming while a reply is arriving. The log gets aria-busy, so screen readers announce the finished answer once instead of every token. Scroll up mid-stream to see the viewport let go.

Pro
Why is my orders query slow?

Scroll on send

useChatConversationContext() exposes scrollToBottom and stopScroll to any component inside the root. Call scrollToBottom() after the user sends a message, so their own message is in view even if they had scrolled up to reread something.

Pro
Which timezone does report 1 use?
Reports use the workspace timezone, set under Settings, then General.
Which timezone does report 2 use?
Reports use the workspace timezone, set under Settings, then General.
Which timezone does report 3 use?
Reports use the workspace timezone, set under Settings, then General.
Which timezone does report 4 use?
Reports use the workspace timezone, set under Settings, then General.

Accessibility

The log is a tab stop, so keyboard users can scroll it in every browser, including Safari, which doesn't make scroll containers focusable on its own.

KeyDescription
TabMoves focus to the log, then to links and buttons inside the messages.
ArrowUp / ArrowDownScrolls the focused log. Scrolling up stops it following new content.
PageUp / PageDownScrolls the focused log by a page.
Home / EndJumps to the first or the newest message. Reaching the bottom starts following again.
Enter / SpaceOn the scroll button, scrolls to the newest message and moves focus back to the log.

ARIA notes:

  • The root renders role="log", which is a polite live region: messages added at the end are announced.
  • The root has aria-label="Conversation" by default. Pass your own aria-label or aria-labelledby to name the thread or translate it.
  • isStreaming sets aria-busy on the log, which holds announcements until the reply is complete. Set aria-busy on the streaming ChatMessage as well.
  • The scroll button is labelled "Scroll to latest message". While hidden it is disabled and its wrapper is aria-hidden, so it leaves the tab order.
  • Code blocks and tables inside messages scroll on their own. Scrolling one of them does not release the conversation until it reaches its own edge.
  • prefers-reduced-motion turns smooth scrolling into an instant jump and removes the fade transition.

Styling

Tailwind override: pass className to the root to size it, and to Content to set the column width and padding. Content has a 32px gap between messages by default.

<ChatConversation className="[--chat-conversation-fade-size:48px]">
  <ChatConversation.Content className="max-w-2xl gap-6 px-4 py-8">
    {messages}
  </ChatConversation.Content>
</ChatConversation>

Data slots and attributes: the component sets these for CSS targeting:

  • data-slot="chat-conversation" on the scrolling root.
  • data-slot="chat-conversation-content" on the message column.
  • data-slot="chat-conversation-scroll-button-container" on the sticky wrapper around the button.
  • data-slot="chat-conversation-scroll-button" on the button, plus the Button attributes.
  • data-state on the root: "at-bottom" while parked at the bottom, "scrolled" otherwise.
  • data-at-top on the root while it is parked at the top.
  • data-fade on the root while fade is on.
  • data-streaming on the root while isStreaming is on.
  • data-state on the button container: "visible" or "hidden".

The fade reads --chat-conversation-fade-size (default 32px). The component writes --chat-conversation-scrollbar-left and --chat-conversation-scrollbar-right on the root with the measured scrollbar width, so the mask never covers a classic scrollbar.

[data-slot="chat-conversation"][data-streaming] {
  /* ... */
}
  • Chat Message: the user and assistant turns that go inside ChatConversation.Content.
  • Markdown: renders assistant replies, with isStreaming for the reply that is still arriving.
  • Chat Loader: the placeholder at the end of the log before the first token arrives.
  • Scroll Area: use it for scroll regions that don't need to follow new content.

API Reference

ChatConversation

The scrolling log. Tracks the scroll position, follows new content while parked at the bottom and provides the context the other parts read. Extends React.ComponentProps<"div">.

Props

PropTypeDefaultDescription
fadebooleantrueFade the top and bottom edges while there is content past them.
initial"smooth" | "instant""instant"How it reaches the bottom on mount. instant is in place before first paint.
resize"smooth" | "instant""smooth"How it follows content growth while parked at the bottom.
isStreamingbooleanfalseA reply is arriving. Sets aria-busy and data-streaming.
aria-labelstring"Conversation"Accessible name of the log.
tabIndexnumber0Makes the log a tab stop for keyboard scrolling.
onScrollReact.UIEventHandler<HTMLDivElement>-Called on every scroll, before the component updates its own state.
refReact.Ref<HTMLDivElement>-The scrolling element.
classNamestring-Tailwind classes merged via cn().
childrenReact.ReactNode-ChatConversation.Content, the scroll button and any sticky overlays.

ChatConversation.Content

The message column: a centered flex column with a 32px gap. The root watches its size, which is how a streamed token or an expanded block keeps the view pinned. Extends React.ComponentProps<"div">. Also exported as ChatConversationContent.

Props

PropTypeDefaultDescription
classNamestring-Tailwind classes merged via cn().
childrenReact.ReactNode-The messages.

ChatConversation.ScrollButton

An icon-only, outline pill Button that scrolls to the newest message and follows again. Renders nothing outside a ChatConversation. Extends the Button props. Also exported as ChatConversationScrollButton.

Props

PropTypeDefaultDescription
tooltipReact.ReactNode-Tooltip shown on hover and focus.
aria-labelstring"Scroll to latest message"Accessible name of the button.
onClickReact.MouseEventHandler<HTMLButtonElement>-Runs first. Call event.preventDefault() to skip the scroll.
disabledboolean-Disables the button. It is always disabled while hidden.
classNamestring-Tailwind classes merged via cn().

useChatConversationContext

Returns the conversation state and controls, or null outside a ChatConversation. Use it in components rendered inside the root.

FieldTypeDescription
isAtBottombooleanParked at the bottom, or following toward it.
isAtTopbooleanParked at the top.
hasOverflowbooleanThe content is taller than the viewport.
isStreamingbooleanMirrors the root isStreaming prop.
scrollToBottom(behavior?: "smooth" | "instant") => voidScrolls to the bottom and follows again. Defaults to "smooth".
stopScroll() => voidStops following where it is, until the reader returns to the bottom.
viewportRefReact.RefObject<HTMLDivElement | null>The scrolling element.

Types

type ChatConversationScrollBehavior = "smooth" | "instant"
 
type ChatConversationScrollState = {
  hasOverflow: boolean
  isAtBottom: boolean
  isAtTop: boolean
}

chatConversationVariants, chatConversationContentVariants, chatConversationScrollButtonVariants and chatConversationScrollButtonContainerVariants are exported for composing your own parts.