Skip to content
ConversationConversationMenu

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.

A coding agent finds a slow query, fixes it and runs the tests.

Installation

$ pnpm dlx shadcn@latest add https://chatcn.dev/r/conversation.json

Usage

tsx
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-anchor and 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

PropTypeNotes
thresholdnumberDistance from the bottom that counts as at the bottom. Defaults to 64
anchorboolean | stringScroll new user turns near the top. A string is a selector for what counts as a turn. Defaults to true
anchorOffsetnumberSpace above an anchored turn. Defaults to 24
initialbooleanStart at the bottom. Defaults to true
ConversationContent.viewportClassNamestringClasses for the scrolling element
ConversationContent.labelstringAccessible 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

KeyAction
TabFocus the transcript
↑, Page Up, HomeScroll up and stop following
↓, Page Down, EndScroll down; reaching the bottom follows again

Accessibility

  • The viewport is a focusable log with aria-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.