ChatInput

A composable chat composer with auto-growing textarea, image attachment row, context chips, and a send button.

Installation

pnpm add @wandercom/design-system-web

Usage

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

export function Example() {
  const [message, setMessage] = useState('');

  return (
    <ChatInput
      onSubmit={(payload) => send(payload)}
      onValueChange={setMessage}
      value={message}
    />
  );
}

The high-level ChatInput composes the media slot (attachments + context chips), textarea, and bottom actions (attach + send) into a single drop-in. Reach for the subcomponents (ChatInputRoot, ChatInputMedia, ChatInputTextarea, etc.) when you need a custom layout.

Default

A bare composer. The textarea auto-grows up to 160px before scrolling internally. Enter inserts a newline; Cmd/Ctrl+Enter submits, as does the send button.

Loading example...
7 lines
<ChatInput
  accept={{ 'image/*': ['.jpg', '.jpeg', '.png'] }}
  onSubmit={(payload) => send(payload)}
  onValueChange={setMessage}
  placeholder="Make edits here"
  value={message}
/>

With attachments

Image attachments render in a horizontal scroll row above the textarea, with edge fades aligned to the composer's outer padding. Files dropped through ChatInputAttach are appended automatically; pass a controlled attachments array and onAttachmentsChange to manage the list yourself (useful for loading states, server upload, etc).

Loading example...
8 lines
<ChatInput
  accept={{ 'image/*': ['.jpg', '.jpeg', '.png'] }}
  attachments={attachments}
  onAttachmentsChange={setAttachments}
  onSubmit={(payload) => send(payload)}
  onValueChange={setMessage}
  value={message}
/>

With context chips

Render small pills above the textarea to expose the chat's active scope — selected formatting tag, referenced calendar, property the agent is operating on. Pass contexts and an onContextRemove callback; the composer renders the chips and wires the remove buttons.

Loading example...
16 lines
import { IconCalendar1 } from '@central-icons-react/round-outlined-radius-2-stroke-1.5/IconCalendar1';
import { IconHome } from '@central-icons-react/round-outlined-radius-2-stroke-1.5/IconHome';

const contexts = [
  { id: 'calendar', label: 'Calendar', icon: <IconCalendar1 /> },
  { id: 'property', label: 'Big Sur Cabin', icon: <IconHome /> },
  { id: 'h1', label: 'H1' },
];

<ChatInput
  contexts={contexts}
  onContextRemove={(id) => removeContext(id)}
  onSubmit={(payload) => send(payload)}
  onValueChange={setMessage}
  value={message}
/>;

Composed

For full control over layout and per-row styling, drop down to the subcomponents. The high-level ChatInput is a thin wrapper around exactly this composition.

Loading example...

Inline

ChatInputInline is a single-line variant — a 44px input row with the same circular primary send button. Use it wherever the chat needs a compact "type a quick reply" affordance (e.g. an "Other" answer on ChatMultiChoiceQuestion) without the textarea, attachments, or context chip surface area of the full ChatInput. The background fades to surface-input on hover, focus, and while a value is present.

Loading example...
5 lines
<ChatInputInline
  aria-label="Reply"
  onSubmit={(value) => respond(value)}
  placeholder="Something else"
/>
19 lines
<ChatInputRoot
  attachments={attachments}
  contexts={contexts}
  onAttachmentsChange={setAttachments}
  onContextRemove={(id) => removeContext(id)}
  onSubmit={(payload) => send(payload)}
  onValueChange={setMessage}
  value={message}
>
  <ChatInputMedia>
    <ChatInputAttachments />
    <ChatInputContexts />
  </ChatInputMedia>
  <ChatInputTextarea placeholder="Make edits here" />
  <ChatInputActions>
    <ChatInputAttach />
    <ChatInputSend />
  </ChatInputActions>
</ChatInputRoot>

Submit behavior

The send button is disabled when the trimmed message is empty AND there are no attachments AND no context chips. Pressing Cmd/Ctrl+Enter inside the textarea (or clicking the send button) calls onSubmit with a ChatInputSubmitPayload:

5 lines
type ChatInputSubmitPayload = {
  message: string; // trimmed textarea value
  attachments: Attachment[];
  contexts: ContextChip[];
};

Clear the textarea and attachments yourself in the submit handler — the composer does not reset state automatically.

Accessibility

The textarea exposes a default aria-label of "Message" (override via the aria-label prop on ChatInputTextarea or the high-level ChatInput). The send and attach buttons are icon-only and ship with aria-labels. Context chip remove buttons are labeled "Remove {label}". Disabled state is propagated from ChatInputRoot to the textarea, attach trigger, and send button.

Props

ChatInput / ChatInputRoot

ChatInput accepts everything ChatInputRoot does, plus placeholder and maxHeight which are forwarded to the internal ChatInputTextarea.

value?:

string
Controlled textarea value.

defaultValue?:

string
Default uncontrolled textarea value.

onValueChange?:

(value: string) => void
Called whenever the textarea value changes.

onSubmit?:

(payload: ChatInputSubmitPayload) => void
Called when the user submits via Cmd/Ctrl+Enter or the send button. Receives the trimmed message plus snapshots of attachments and contexts.

attachments?:

Attachment[]
Controlled list of image attachments. Pair with `onAttachmentsChange` to manage state externally.

defaultAttachments?:

Attachment[]
Default uncontrolled attachments list.

onAttachmentsChange?:

(attachments: Attachment[]) => void
Called whenever attachments are added or removed (including via the attach trigger).

contexts?:

ContextChip[]
Controlled list of context chips. Removal is delegated to `onContextRemove`.

onContextRemove?:

(id: string) => void
Called when a chip's remove button is clicked.

placeholder?:

string
Placeholder text rendered in the textarea. Defaults to "Make edits here".

maxHeight?:

number
Maximum textarea height in pixels before internal scrolling begins. Defaults to 160.

disabled?:

boolean
Disables the textarea, attach trigger, and send button.

accept?:

Accept
MIME type / extension map forwarded to the file picker.

maxFiles?:

number
Maximum number of files accepted at once.

maxSize?:

number
Max file size in bytes.

multiple?:

boolean
Allow multiple file selection. Defaults to true.

onFilesAccepted?:

(files: File[]) => void
Forwarded to the file picker. Fires in addition to the internal append-to-attachments behavior.

onFilesRejected?:

(rejections: FileRejection[]) => void
Forwarded to the file picker when validation fails.

className?:

string
Additional CSS classes to apply to the rounded shell.

ChatInputTextarea

Inherits all native <textarea> props except value, defaultValue, and onChange (managed by the root).

maxHeight?:

number
Maximum height in pixels before the textarea begins to scroll internally. Defaults to 160.

placeholder?:

string
Placeholder text. Defaults to "Make edits here".

aria-label?:

string
Accessible label. Defaults to "Message".

className?:

string
Additional CSS classes to apply.

ChatInputContextChip

label

string
Visible label rendered inside the chip.

icon?:

ReactNode
Optional leading icon (typically a 12–16px Central icon). When `onRemove` is set, the icon replaces the default × glyph inside the leading remove button.

onRemove?:

() => void
When provided, renders a leading remove button that fires this callback. Auto-labeled "Remove {label}".

className?:

string
Additional CSS classes to apply.

ChatInputAttach

Inherits the FilePickerChatTrigger API. Each prop overrides the matching value inherited from ChatInputRoot.

accept?:

Accept
Override the root-level accept map for this trigger.

maxFiles?:

number
Override the root-level maxFiles cap.

maxSize?:

number
Override the root-level maxSize cap.

multiple?:

boolean
Override the root-level multiple flag.

onFilesAccepted?:

(files: File[]) => void
Override the root-level callback. Bypasses the default append-to-attachments behavior.

onFilesRejected?:

(rejections: FileRejection[]) => void
Override the root-level rejection callback.

aria-label?:

string
Accessible label. Defaults to "Attach file".

ChatInputSend

Inherits the Button API except size, variant, asChild, type, and children, which are fixed by the component.

icon?:

ReactNode
Override the icon rendered inside the button. Defaults to the up-arrow.

aria-label?:

string
Accessible label. Defaults to "Send message".

disabled?:

boolean
Override the auto-derived disabled state (which is true when the message, attachments, and contexts are all empty).

className?:

string
Additional CSS classes to apply.

ChatInputInline

Standalone single-line variant. Does not require ChatInputRoot. Inherits all native <input> props except value, defaultValue, onChange, onSubmit, and ref.

value?:

string
Controlled value.

defaultValue?:

string
Default uncontrolled value.

onValueChange?:

(value: string) => void
Called whenever the input value changes.

onSubmit?:

(value: string) => void
Called when the user submits via Enter or the send button. Receives the trimmed value. Suppressed when the trimmed value is empty.

placeholder?:

string
Placeholder text rendered in the input.

aria-label?:

string
Accessible label for the input. Defaults to "Reply". The placeholder is not a substitute for a label.

aria-labelledby?:

string
ID of an external labeling element. Overrides `aria-label` when set.

hideSend?:

boolean
When true, hide the send button. Submission still works via Enter.

sendIcon?:

ReactNode
Override the icon rendered inside the send button. Defaults to the up-arrow.

sendAriaLabel?:

string
Accessible label for the icon-only send button. Defaults to "Submit".

disabled?:

boolean
Disables the input and send button.

ref?:

Ref<HTMLInputElement>
Forwarded to the underlying `<input>` element for imperative focus.

className?:

string
Additional CSS classes to apply to the wrapper.

Types

18 lines
type Attachment = {
  id: string;
  src: string;
  alt: string;
  loading?: boolean;
};

type ContextChip = {
  id: string;
  label: string;
  icon?: ReactNode;
};

type ChatInputSubmitPayload = {
  message: string;
  attachments: Attachment[];
  contexts: ContextChip[];
};
ChatInput