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.

Morning — anything urgent in the book today?
Two items: the Hargreaves ISA drifted outside tolerance, and the Patel SIPP review is due Friday.
Start with the drift. How far out is it?
Equities sit at 68% against a 60% target — driven by last month's rally rather than contributions.
A rebalance back to target would mean trimming roughly £14,200 of equity exposure.
Any CGT impact if we trim inside the ISA?
None — disposals inside the ISA wrapper are exempt, so the trim is tax-neutral.
Good. Draft the rebalance note for my review.
Drafted and saved to the Hargreaves file — it covers the drift cause, the proposed trades, and the tax position.
<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.

Ask Reva
Drift, review prep, fee disclosures — ask anything about your client book.
<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 with tabIndex={0} and an inset focus ring. Name it per conversation with aria-label on ConversationContent (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 — while aria-relevant="additions" would silence everything after a streamed message's first chunk on exactly those screen readers, and VoiceOver ignores the attribute either way.
  • ConversationScrollButton ships aria-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

ComponentPropTypeDefaultDescription
ConversationinitialScrollBehavior | boolean"smooth"Scroll animation on mount ("instant" under reduced motion). false skips the initial scroll.
ConversationresizeScrollBehavior"smooth"Scroll animation when content resizes ("instant" under reduced motion).
ConversationContentchildrenReactNode | (context) => ReactNodeMessage rows, or a render prop receiving the conversation context.
ConversationContentaria-labelstring"Conversation"Accessible name for the role="log" scroll region.
ConversationContentscrollClassNamestringExtra classes for the scroll element; className styles the inner content wrapper.
ConversationScrollButtonvariant / sizeIconButton variants"secondary" / "sm"Frosted-glass jump-to-latest control.
ConversationScrollButtoninitial / animate / exit / transitionMotion propsfade + scale on the house springOverride the entrance/exit motion.
ConversationEmptyStatetitle / description / iconReactNodeComposed into the Empty family; pass children to replace the whole header.

On this page