Conversation
The thread scroller. Sticks to the bottom only while you are there, lets go the moment you scroll up, and scrolls each new question to the top so the reply has room.
On this page
A coding agent finds a slow query, fixes it and runs the tests.
Installation
Usage
import { Conversation, ConversationContent } from "@/components/ui/chatcn/conversation"
import { Message, MessageContent } from "@/components/ui/chatcn/message"
import { MessageParts } from "@/components/ui/chatcn/message-parts"
import { ScrollToBottom } from "@/components/ui/chatcn/scroll-to-bottom"
<Conversation className="h-dvh">
<ConversationContent>
{messages.map((message) => (
<Message key={message.id} from={message.role}>
<MessageContent>
<MessageParts message={message} streaming={isStreaming(message)} />
</MessageContent>
</Message>
))}
</ConversationContent>
<ScrollToBottom />
</Conversation>Give Conversation a height, or place it in a flex column. The message column inside is capped at 720 px with a 24 px gap, the measure the rest of the kit is designed for.
How scrolling behaves
- While you are within 64 px of the bottom, growing content is followed with a critically damped chase, so streamed lines glide instead of jumping.
- Any upward wheel, touch drag, key or scrollbar drag releases it in the same event. The thread never pulls you back.
- Scrolling back into the bottom 64 px, or pressing the pill, sticks again.
- When a new user message arrives, it scrolls to 24 px from the top and a spacer below it leaves room for the reply. The reply fills the space; once it is taller than the viewport, normal sticking takes over.
- History loaded above the reader keeps their place, through
overflow-anchorand a manual fallback for engines without it. - A ResizeObserver follows content that grows after render: images, code blocks, expanded tool calls.
- Messages present on mount never animate and never count as unread.
Examples
Load earlier messages
Older turns are prepended above the reader. What they were reading does not move.
Prepending history keeps the reader exactly where they were.
Props
| Prop | Type | Notes |
|---|---|---|
threshold | number | Distance from the bottom that counts as at the bottom. Defaults to 64 |
anchor | boolean | string | Scroll new user turns near the top. A string is a selector for what counts as a turn. Defaults to true |
anchorOffset | number | Space above an anchored turn. Defaults to 24 |
initial | boolean | Start at the bottom. Defaults to true |
ConversationContent.viewportClassName | string | Classes for the scrolling element |
ConversationContent.label | string | Accessible name of the log. Defaults to Conversation |
useConversation() returns isAtBottom, isSticky, isFar, unread, scrollToBottom(), scrollToTurn(element) and announce(text) for custom controls. The engine is also available on its own as useChatcnStickToBottom.
Keyboard
| Key | Action |
|---|---|
Tab | Focus the transcript |
↑, Page Up, Home | Scroll up and stop following |
↓, Page Down, End | Scroll down; reaching the bottom follows again |
Accessibility
- The viewport is a focusable
logwitharia-live="off", so screen readers are never fed tokens one by one. - When a message finishes streaming, its text is read once through a polite live region.
- Under reduced motion every scroll is instant.
Motion
- Following uses an exponential chase with a 70 ms time constant: fast, interruptible, no overshoot.
- New user messages enter with opacity and 8 px of travel over 200 ms. Assistant messages fade in over 150 ms. Restored history does not animate.