- 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
Chat
A composed chat experience with messages, tool turns, thinking state, and a composer.
pnpm add @wandercom/design-system-web
ChatComposed wires together the seven chat primitives — ChatContainer, ChatMessage, Markdown, ChatThinking, ChatMultiChoiceQuestion, ChatMultiOptionQuestion, and ChatInput — into a single drop-in block. Pass an ordered items array describing the transcript and forward the composer state through props.
Each entry is either a ChatMessageItem (a standard message bubble, with optional markdown content, action row, avatar, and thinking shimmer) or a ChatToolItem (a right-aligned user bubble that surfaces a past Q:/A: for a tool call the user has already responded to).
Live, unanswered questions belong on the activeQuestion prop instead — when set, the corresponding question component replaces the composer at the bottom of the layout. The question's onSubmit is responsible for clearing activeQuestion upstream and appending a ChatToolItem to items so the response shows up in the transcript.
import { ChatComposed, type ChatItem } from '@wandercom/design-system-web/blocks/chat';
import type { Attachment, ContextChip } from '@wandercom/design-system-web/ui/chat-input';
import { useState } from 'react';
export function Example() {
const [message, setMessage] = useState('');
const [items, setItems] = useState<ChatItem[]>([
{ id: '1', from: 'user', content: 'Looking for a quiet weekend in November.' },
{
id: '2',
from: 'assistant',
content: '### A few options\n\n- Big Sur cliffside\n- Ojai retreat\n- Aspen lodge',
markdown: true,
},
]);
return (
<div className="h-[600px]">
<ChatComposed
items={items}
inputValue={message}
onInputChange={setMessage}
onSubmit={(payload) => sendMessage(payload)}
placeholder="Ask about a stay…"
/>
</div>
);
}The wrapper defaults to h-full w-full so it fills its parent. Wrap it in a sized container (e.g. h-[600px]) so the transcript scroll region is bounded.
The block renders different surfaces based on item shape:
Assistant message with markdown — set markdown: true and pass a markdown string as content. The body is rendered through Markdown so headings, lists, code blocks, and links pick up the design system's typography.
Assistant thinking — set status: "thinking" on a message. The body is replaced with ChatThinking (a polite live-region shimmer). Swap status back to undefined and provide real content once the response resolves.
Active multi-choice question — pass activeQuestion={{ kind: "multi-choice", ...ChatMultiChoiceQuestionProps }}. Replaces the composer with a vertical list of single-select option cards with optional free-form "Other" answer.
Active multi-option question — pass activeQuestion={{ kind: "multi-option", ...ChatMultiOptionQuestionProps }}. Replaces the composer with a checklist with select-all and a footer count + submit button.
Past tool response — append a ChatToolItem to items with from: "user" and a tool snapshot of the question and the user's selection. Renders as a right-aligned user bubble showing Q: {question} / A: {selected}.
User message with attachments — pass attachments and onAttachmentsChange to control the composer's image attachments row. After submit, append the image URLs to the next message via the meta slot or as inline ReactNode content.
The items array uses a discriminated union — entries with a tool field render as right-aligned user-response bubbles, everything else renders as a ChatMessage.
import { IconClipboard } from '@central-icons-react/round-outlined-radius-2-stroke-1.5/IconClipboard';
import type { ChatItem } from '@wandercom/design-system-web/blocks/chat';
const items: ChatItem[] = [
// Plain user message
{ id: 'u-1', from: 'user', content: 'Hello' },
// Markdown assistant response with action row
{
id: 'a-1',
from: 'assistant',
content: '### Here are a few options\n\n- Big Sur\n- Ojai',
markdown: true,
actions: [{ label: 'Copy', icon: <IconClipboard />, onClick: copy }],
},
// Thinking shimmer
{ id: 'a-2', from: 'assistant', status: 'thinking', content: '' },
// Past multi-choice response — right-aligned user bubble.
{
id: 'tool-1',
from: 'user',
tool: {
kind: 'multi-choice',
question: 'How should we handle photos?',
selected: 'Use new high quality photos',
},
},
// Past multi-option response — right-aligned user bubble.
{
id: 'tool-2',
from: 'user',
tool: {
kind: 'multi-option',
question: 'Which amenities matter most?',
selected: ['Private pool', 'On-property chef'],
},
},
];Live questions go through activeQuestion rather than items — they replace the composer until the user submits:
<ChatComposed
items={items}
activeQuestion={{
kind: 'multi-option',
question: 'Which amenities matter most?',
options: amenityOptions,
value: selected,
onChange: setSelected,
onSubmit: (values) => {
setItems((prev) => [
...prev,
{
id: crypto.randomUUID(),
from: 'user',
tool: {
kind: 'multi-option',
question: 'Which amenities matter most?',
selected: values.map((v) => labelByValue.get(v) ?? v),
},
},
]);
setActiveQuestion(undefined);
},
}}
inputValue={message}
onInputChange={setMessage}
onSubmit={handleSubmit}
/>Use header to render content above the transcript (e.g. a thread title, a back button, a "clear" affordance). Use emptyState to replace the transcript when items is empty — the composer remains visible.
<ChatComposed
header={<ChatHeader title="New thread" onClear={resetThread} />}
emptyState={<EmptyChat onPromptClick={handlePromptClick} />}
items={items}
inputValue={message}
onInputChange={setMessage}
onSubmit={handleSubmit}
/>The composer is fully controlled. Forward inputValue / onInputChange for the textarea, and optionally attachments / onAttachmentsChange and contexts / onContextRemove for the attachments row and context chips. onSubmit receives a ChatInputSubmitPayload containing the trimmed message, the snapshot of attachments, and the active context chips at submit time.
const [message, setMessage] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [contexts, setContexts] = useState<ContextChip[]>([
{ id: 'calendar', label: 'Calendar', icon: <IconCalendar1 /> },
]);
<ChatComposed
accept={{ 'image/*': ['.jpg', '.jpeg', '.png'] }}
attachments={attachments}
contexts={contexts}
inputValue={message}
items={items}
maxFiles={4}
onAttachmentsChange={setAttachments}
onContextRemove={(id) => setContexts((prev) => prev.filter((c) => c.id !== id))}
onInputChange={setMessage}
onSubmit={(payload) => {
sendMessage(payload);
setMessage('');
}}
placeholder="Ask about a stay…"
/>items:
inputValue:
onInputChange:
attachments?:
onAttachmentsChange?:
contexts?:
onContextRemove?:
onSubmit?:
placeholder?:
accept?:
maxFiles?:
header?:
emptyState?:
showNewMessageButton?:
newMessageLabel?:
activeQuestion?:
renderResponse?:
className?:
A discriminated union — either a ChatMessageItem (standard message) or a ChatToolItem (inline tool turn).
type ChatItem = ChatMessageItem | ChatToolItem;id:
from:
content:
markdown?:
actions?:
avatar?:
status?:
live?:
A snapshot of the user's response to a past tool call. Rendered as a right-aligned user bubble showing the original question and the selected answer for transcript context. Live, unanswered questions belong on activeQuestion instead.