- Accordion
- Avatar
- Badge
- Breadcrumb
- Button
- Calendar
- ChatContainer
- ChatInput
- ChatMessage
- ChatMultiChoiceQuestion
- ChatMultiOptionQuestion
- ChatThinking
- Checkbox
- Combobox
- Container
- CurrencyInput
- DistributionSlider
- Drawer
- Dropdown
- FilePicker
- Grid
- Heading
- Image
- Input
- InputGroup
- Label
- Logo
- MapPin
- Markdown
- Modal
- NativeSelect
- NumberInput
- OptionSlider
- OtpInput
- PhoneInput
- Popover
- Progress
- PropertyCalendar
- RadioGroup
- RadioGroupCards
- ResponsiveModal
- ScrollArea
- SearchBar
- SearchBarFallback
- SearchInput
- Select
- Separator
- Spinner
- Switch
- Table
- Tabs
- Text
- Textarea
- TimePicker
- Toast
- Toggle
- ToggleCard
- ToggleGroup
- Toolbar
- Tooltip
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.
pnpm add @wandercom/design-system-web
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.
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.
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.
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.
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.
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.
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>;The behavior is exposed as a standalone hook so it can drive custom layouts that don't fit the primitives.
import { useStickToBottom } from '@wandercom/design-system-web/hooks/use-stick-to-bottom';
const { scrollRef, contentRef, isAtBottom, scrollToBottom } =
useStickToBottom();behavior?
contentClassName?
newMessageLabel?
showNewMessageButton?
className?
behavior?
transcriptLabel?
className?
asChild?
className?
asChild?
className?
label?
asChild?
className?
threshold?
behavior?
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.
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.
- The initial mount jumps to the bottom with
behavior: "instant"regardless of thebehaviorprop, 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
ResizeObserveronly re-pins whileisAtBottomis true, so streaming content into a long transcript that the user is reading does not yank their scroll position.