ChatContainer

A scrollable transcript region that auto-pins to the bottom as messages arrive and stream, with a "new messages" affordance when the user is reading history.

Installation

pnpm add @wandercom/design-system-web

Usage

ChatContainer is the scroll region for a chat transcript. It pins to the bottom on mount, tracks the bottom edge as new messages arrive or as the trailing message streams in, and surfaces a "New messages" pill when the user has scrolled up.

13 lines
import { ChatContainer } from '@wandercom/design-system-web/ui/chat-container';

export function Example({ messages }) {
  return (
    <div className="flex h-[480px] flex-col">
      <ChatContainer>
        {messages.map((m) => (
          <ChatMessage key={m.id} {...m} />
        ))}
      </ChatContainer>
    </div>
  );
}

The container is unopinionated about visual presentation — it provides scroll behavior, vertical layout, and a default gap between children. Apply borders, padding, height, and background at the parent or via className / contentClassName.

Default

The single-component composition wires up the four primitives for the common case: a scroll root, a content wrapper, a scroll anchor, and the floating "New messages" pill.

Loading example...

Streaming

As the trailing message grows in size — for example, while assistant tokens are streamed in — the container tracks the new bottom without jumping. If the user scrolls up to read history, streaming continues to render but the scroll position holds steady.

Loading example...

New messages

When the user has scrolled up and a new message is appended, the floating pill fades and slides in. Clicking it scrolls back to the bottom and re-engages auto-stick. While the user is at the bottom the pill is hidden and removed from the tab order.

Loading example...

Composition

For tighter control, compose the primitives directly. This is useful when you need a custom anchor placement, a non-default scroll behavior, or a custom pill.

16 lines
import {
  ChatContainerContent,
  ChatContainerNewMessageButton,
  ChatContainerRoot,
  ChatContainerScrollAnchor,
} from '@wandercom/design-system-web/ui/chat-container';

<ChatContainerRoot behavior="instant">
  <ChatContainerContent className="gap-4 p-6">
    {messages.map((m) => (
      <ChatMessage key={m.id} {...m} />
    ))}
    <ChatContainerScrollAnchor />
  </ChatContainerContent>
  <ChatContainerNewMessageButton label="Jump to latest" />
</ChatContainerRoot>;

Hook

The behavior is exposed as a standalone hook so it can drive custom layouts that don't fit the primitives.

4 lines
import { useStickToBottom } from '@wandercom/design-system-web/hooks/use-stick-to-bottom';

const { scrollRef, contentRef, isAtBottom, scrollToBottom } =
  useStickToBottom();

Props

ChatContainer

behavior?

'smooth' | 'instant'
Scroll behavior used when auto-pinning to the bottom and when the pill is clicked. Defaults to 'smooth'.

contentClassName?

string
Class names forwarded to the inner content wrapper.

newMessageLabel?

string
Visible label for the floating pill. Defaults to 'New messages'.

showNewMessageButton?

boolean
When false, suppresses the floating pill. Defaults to true.

className?

string
Class names forwarded to the scroll region wrapper.

ChatContainerRoot

behavior?

'smooth' | 'instant'
Scroll behavior used when auto-pinning. Defaults to 'smooth'.

transcriptLabel?

string
Accessible label for the scrollable transcript region. Defaults to 'Chat transcript'.

className?

string
Class names forwarded to the relative wrapper.

ChatContainerContent

asChild?

boolean
When true, renders children and merges props onto the child element instead of the default `<div>`.

className?

string
Class names forwarded to the inner content wrapper. The default layout is `flex flex-col gap-3`.

ChatContainerScrollAnchor

asChild?

boolean
When true, renders children and merges props onto the child element instead of the default `<div>`.

className?

string
Class names forwarded to the 1px sentinel rendered at the bottom of the content.

ChatContainerNewMessageButton

label?

string
Visible label inside the pill. Defaults to 'New messages'.

asChild?

boolean
When true, renders the provided children as the trigger inside the floating positioner instead of the default `<Button>`. Use to swap in a custom trigger while preserving the scroll-to-bottom behavior.

className?

string
Class names forwarded to the underlying Button.

useStickToBottom

threshold?

number
Pixel distance from the bottom that still counts as 'at bottom'. Defaults to 96.

behavior?

'smooth' | 'instant'
Default scroll behavior used by `scrollToBottom`. Defaults to 'smooth'.

Returns { scrollRef, contentRef, isAtBottom, hasNewMessages, scrollToBottom }. Attach scrollRef to the scroll container and contentRef to the inner content element so size changes can re-pin the bottom.

Accessibility

The pill is a Button with a visible label and an icon, so it is announced to assistive tech. While the user is at the bottom the pill is hidden visually, marked aria-hidden, and removed from the tab order — it only becomes interactive when there is something new to scroll to.

The container deliberately does not declare itself an aria-live / role="log" region. A live region wrapping every message would re-announce on every streamed token (e.g. while Markdown is rendering token-by-token). Live-region semantics are scoped to individual arriving messages instead — set live on ChatMessageRoot (or ChatMessage) for the assistant's most recent streaming turn so screen readers announce the settled text once it stabilizes. The ChatThinking shimmer carries its own aria-live="polite" for the awaiting state.

The scroll region is a standard scrollable <div> with aria-label="Chat transcript". Keyboard users can scroll with Page Up / Page Down once the region has focus. If you need full keyboard parity for a chat transcript, render messages as focusable items so arrow keys can step through history.

Edge cases

  • The initial mount jumps to the bottom with behavior: "instant" regardless of the behavior prop, so the first paint is anchored without an animation.
  • The threshold (default 96px) absorbs sub-pixel rendering and momentum scrolling on trackpads — a user is considered "at bottom" if they're within 96px of it.
  • The ResizeObserver only re-pins while isAtBottom is true, so streaming content into a long transcript that the user is reading does not yank their scroll position.
ChatContainer