Combobox

A text input that filters a list of suggestions as you type — pick one, pick many (as chips), or allow free text — with focus staying in the input while the list is navigated.

Basic usage

Install the package:

pnpm add @design-schema/react @design-schema/tokens

Import the stylesheet once for the app, then the component:

import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { Combobox } from '@design-schema/react';

Examples

import { Combobox, type ListboxOption } from '@design-schema/react';

const FRUITS: ListboxOption[] = [
  { value: 'apple', label: 'Apple' },
  { value: 'apricot', label: 'Apricot' },
  { value: 'banana', label: 'Banana' },
];

<Combobox label="Fruit" name="fruit" options={FRUITS} />

Props

18 rows
PropTypeDefaultDescriptionPlatforms
label requiredstring—

Visible label. Always rendered.

Accessibility: label/for on the input; accessibilityLabel on native.

web, lit, rn, swiftui
name requiredstring—

Field name for the Form.

web, lit, rn, swiftui
options requiredarray ListboxItem[] (options and one level of groups, as Listbox)—

The full option set, or the current page of results when `filter` is `async`. Passed through to the Listbox after filtering.

web, lit, rn, swiftui
valuestring | string[]—

Controlled selected value(s). With `multiple`, an array. With `allowCustom`, a value not in `options` is a custom entry.

web, lit, rn, swiftui
defaultValuestring | string[]—

Initial value(s).

web, lit, rn, swiftui
openboolean—

Controlled popup state, for programmatic use and for stories and tests. Omit for the typing-driven default. Opening the list this way claims DOM focus for the input when focus is not already inside the field — `aria-activedescendant` announces nothing otherwise — but never takes it from a focused clear or chip-remove Button; there is no way to open the list without moving focus.

web, lit, rn, swiftui
inputValuestring—

Controlled text of the input (what the user has typed). Usually uncontrolled; controlled by consumers driving `async` filtering.

web, lit, rn, swiftui
multiplebooleanfalse

Pick many: selected options appear as chips before the input, each removable; the list stays open while toggling; Backspace in an empty input removes the last chip. Uses the same Listbox engine as Select.

web, lit, rn, swiftui
allowCustombooleanfalse

Typed text that matches no option can be committed as a value (tags, emails). Enter or a comma commits it; the list shows `copy.addCustom` as a synthetic first row, suppressed when the trimmed text already matches an existing option by either its `value` or its `label`. Committing text that matches an option that way (Enter or a comma, same case- and diacritic-insensitive match) commits that option's `value`, never a custom string. If the matching option is disabled, the row stays suppressed and the commit does nothing (neither the disabled value nor a custom string). With `multiple`, text matching an already-selected option leaves it selected (no `onChange`, unlike Enter on its row, which toggles) and clears the text — that clear does fire `onInputChange`, like any other commit. The synthetic row is independent of `filter`: it shows with `filter: none` too, since only a match against an existing option suppresses it. A comma typed when there is nothing to commit (empty text, or only a disabled match) is dropped and the text before it kept.

web, lit, rn, swiftui
filterstartsWith | contains | none | asynccontains

How typing narrows `options`: by prefix, by substring (default), not at all (the list is a picker; typing is type-ahead — it opens the list and moves the active option to the first label starting with the typed characters, without filtering), or by the consumer (`async`: the component shows `copy.loading` and the consumer updates `options` from `onInputChange`).

web, lit, rn, swiftui
placeholderstring—

Example input shown while empty. Never the only description.

web, lit, rn, swiftui
descriptionstring—

Helper text under the label.

web, lit, rn, swiftui
requiredbooleanfalse

Must have a value to submit.

web, lit, rn, swiftui
disabledbooleanfalse

Not editable, not submitted, still readable and focusable.

web, lit, rn, swiftui
invalidbooleanfalse

Marks the field invalid.

web, lit, rn, swiftui
errorstring—

Error message; implies invalid. An empty string is not a message (as Input): nothing renders, though a Form entry still marks the field.

web, lit, rn, swiftui
loadingbooleanfalse

For `async`: show the loading row and announce it. The consumer sets it around its request.

web, lit, rn, swiftui
clearablebooleantrue

Show a clear button when there is a value or text. It also gates Escape-clears-text, per the keyboard table: a combobox without a clear button offers no way to empty the text either. Lit attribute: `no-clear`.

web, lit, rn, swiftui

Events

3 rows
EventTypeDescriptionPlatforms
onChangeonChange / change

Fired when the selected value(s) change (array with `multiple`; custom entries included when `allowCustom`).

web, lit, rn, swiftui
onInputChangeonInputChange / input-change

Fired on every text change the user causes — each keystroke, and the text a commit, Escape-to-clear or the clear button leaves behind (so `async` consumers can reset) — with the input text. Not fired when a controlled `value` change rewrites the label, nor when a commit, Escape or the clear button leaves the text unchanged; "unchanged" is measured against the text the input is showing now, which for a controlled `inputValue` is the consumer's prop, so a consumer that does not apply the reported text keeps receiving the same event. The hook for `async` filtering.

web, lit, rn, swiftui
onOpenChangeonOpenChange / open-change

Fired when the list opens or closes — including the closes the component causes itself (a blur, a single-select commit, Escape, Tab), so a controlled `open` can always be tracked.

web, lit, rn, swiftui

Accessibility

Decisions this component leaves to you

Supply the label. It is associated with the control for you, but the words are yours — a placeholder is not a label.

Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.

Supply the error text when the value is invalid. The component links and announces it; it cannot write it.

Required props that carry accessibility meaning: label. Each is described in the props table above.

Implicit ARIA role: combobox.

Anatomy

labeldescriptionfieldchipschipchipRemoveinputclearButtontoggleButtonpopuplistboxstatuserrorMessage

What generation guarantees

Label association — The label is programmatically associated with the control, not merely placed beside it.

Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.

Expanded state — Open and closed are exposed programmatically (`aria-expanded`), not only visually.

Selected state — Selection is exposed programmatically (`aria-selected` / `aria-checked`).

Arrow navigation — Arrow keys move between items; Home and End jump to the ends.

Escape dismisses — Escape closes it from anywhere inside.

Live region — Changes are announced through a live region, without moving focus.

Error identification — An invalid value is identified in text and linked to the control — never signalled by colour alone.

Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.

Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.

Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.

Target size — Interactive targets are at least 44×44 CSS pixels — the touch-first size, not the minimum.

Combobox keyboard support

9 rows
KeyAction
ArrowDownOpens the list (if closed) with the selected option active, else the first; when already open, moves the active option down (from none: the first match); focus stays in the input.
ArrowUpOpens the list with the selected option active, else the last; when already open, moves the active option up.
EnterCommits the active option (single: closes; multiple: toggles and stays open); with allowCustom and no active option, commits the typed text. (list open)
EscapeCloses the list if open; if closed and clearable, clears the input text only (the value is kept; the clear button is what empties the value). (list open)
TabCloses the list and moves focus on. A highlighted option is NOT committed by Tab, in single or multiple mode (typing intent is ambiguous). (list open)
BackspaceIn an empty input with chips, removes the last chip. (multiple)
Home or EndMove the text caret (input semantics), never the list.
,With allowCustom, commits the typed text exactly as Enter does: multiple clears the input and stays open; single shows the committed text and closes. (allowCustom)
Alt+ArrowDownOpens the list with the selected option active, or no active option when nothing is selected; changes nothing while the list is already open, though the default action is still suppressed — ArrowDown and ArrowUp never move the text caret, open or closed.

Contrast pairs

5 rows
ForegroundBackgroundLevel
color.foregroundcolor.backgroundAA
color.foreground.mutedcolor.backgroundAA
color.foregroundcolor.background.strongAA
color.foreground.dangercolor.backgroundAA
color.border.strongcolor.backgroundAA (non-text)

This component does not animate, so it has no reduced-motion behaviour to describe.