Conversation
The chat scroll shell — sticks to the bottom while streaming content grows, releases when the user scrolls up, with a floating jump-to-latest button.
<Conversation className="h-80 flex-none"> <ConversationContent> {messages.map((message) => ( <Message key={message.id} from={message.from} initial={false}> <MessageContent>{message.text}</MessageContent> </Message> ))} </ConversationContent> <ConversationScrollButton /></Conversation>Usage
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
useConversationContext,
} from "@reva/ui";Conversation owns chat scroll behaviour: it sticks to the bottom while streaming content grows and releases as soon as the user scrolls up (built on use-stick-to-bottom — don't hand-roll scroll position math around it). The root is the positioning anchor and context provider; ConversationContent renders the actual scroll region.
The conversation needs a bounded height
The root defaults to flex-1, which only bounds it inside a fixed-height flex column (flex flex-col h-full min-h-0 — the canonical chat-panel layout). Anywhere else, give it the bound yourself: className="h-80 flex-none" (as in the demos here) or h-full against a sized parent. Unbounded, the page scrolls instead of the conversation and stick-to-bottom never engages.
Message rows go in as direct children of ConversationContent, which deliberately adds no gap-*: Message rows own the grouping rhythm with their own margins, and a gap would stack with them. Padding is fine — the content wrapper ships p-4.
Composition
Use the following composition to build a Conversation:
Conversation (bounded height — flex-1 column or fixed)
├── ConversationContent (the scroll region, role="log")
│ └── Message (× n) (direct siblings — no gap wrapper)
│ └── MessageContent
└── ConversationScrollButton (after ConversationContent)ConversationScrollButton must render inside Conversation (it reads the scroll context); the canonical spot is as a sibling after ConversationContent, so the floating button sits outside the live region.
Examples
Empty state
ConversationEmptyState composes the Empty family — icon, title, and description cover the common case, or pass children to replace the composed header entirely. Inside ConversationContent it fills the viewport and centres itself.
<Conversation className="h-80 flex-none"> <ConversationContent> <ConversationEmptyState icon={<Sparkle />} title="Ask Reva" description="Drift, review prep, fee disclosures — ask anything about your client book." /> </ConversationContent></Conversation>Custom at-bottom UI
For at-bottom behaviour beyond the stock button — an inline "new messages" pill, auto-focus handoff — read the conversation context. Use the re-exported useConversationContext: it is the library hook from the same module instance the components use, so it sees their provider. Importing the hook from use-stick-to-bottom yourself risks resolving a second copy of the library whose context the components never provided.
function NewMessagesPill() {
const { isAtBottom, scrollToBottom } = useConversationContext();
if (isAtBottom) return null;
return (
<Button size="sm" onClick={() => scrollToBottom()}>
New messages
</Button>
);
}Accessibility
- The scroll region is a focusable
role="log"element — an implicit polite live region, keyboard-scrollable withtabIndex={0}and an inset focus ring. Name it per conversation witharia-labelonConversationContent(default"Conversation"). - Streaming announcements keep the log's default
aria-relevant("additions text"), deliberately: screen readers that respect the attribute (NVDA, JAWS) diff text changes and politely read only the appended text at idle, so a streaming reply reads progressively rather than per-token — whilearia-relevant="additions"would silence everything after a streamed message's first chunk on exactly those screen readers, and VoiceOver ignores the attribute either way. ConversationScrollButtonshipsaria-label="Scroll to bottom". On keyboard activation it hands focus to the scroll region before unmounting, so focus never drops to the body.- Reduced motion: the stick-to-bottom scroll animations (
initial/resize) switch from"smooth"to"instant", and the scroll button's fade/scale entrance is skipped.
Props
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
Conversation | initial | ScrollBehavior | boolean | "smooth" | Scroll animation on mount ("instant" under reduced motion). false skips the initial scroll. |
Conversation | resize | ScrollBehavior | "smooth" | Scroll animation when content resizes ("instant" under reduced motion). |
ConversationContent | children | ReactNode | (context) => ReactNode | — | Message rows, or a render prop receiving the conversation context. |
ConversationContent | aria-label | string | "Conversation" | Accessible name for the role="log" scroll region. |
ConversationContent | scrollClassName | string | — | Extra classes for the scroll element; className styles the inner content wrapper. |
ConversationScrollButton | variant / size | IconButton variants | "secondary" / "sm" | Frosted-glass jump-to-latest control. |
ConversationScrollButton | initial / animate / exit / transition | Motion props | fade + scale on the house spring | Override the entrance/exit motion. |
ConversationEmptyState | title / description / icon | ReactNode | — | Composed into the Empty family; pass children to replace the whole header. |