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| Prop | Type | Default | Description |
|---|---|---|---|
| label required | string | — | Visible label. Always rendered. Accessibility: label/for on the input; accessibilityLabel on native. |
| name required | string | — | Field name for the Form. |
| options required | array 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. |
| value | string | string[] | — | Controlled selected value(s). With `multiple`, an array. With `allowCustom`, a value not in `options` is a custom entry. |
| defaultValue | string | string[] | — | Initial value(s). |
| open | boolean | — | 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. |
| inputValue | string | — | Controlled text of the input (what the user has typed). Usually uncontrolled; controlled by consumers driving `async` filtering. |
| multiple | boolean | false | 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. |
| allowCustom | boolean | false | 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. |
| filter | startsWith | contains | none | async | contains | 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`). |
| placeholder | string | — | Example input shown while empty. Never the only description. |
| description | string | — | Helper text under the label. |
| required | boolean | false | Must have a value to submit. |
| disabled | boolean | false | Not editable, not submitted, still readable and focusable. |
| invalid | boolean | false | Marks the field invalid. |
| error | string | — | Error message; implies invalid. An empty string is not a message (as Input): nothing renders, though a Form entry still marks the field. |
| loading | boolean | false | For `async`: show the loading row and announce it. The consumer sets it around its request. |
| clearable | boolean | true | 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`. |
Events
3 rows| Event | Type | Description |
|---|---|---|
| onChange | onChange / change | Fired when the selected value(s) change (array with `multiple`; custom entries included when `allowCustom`). |
| onInputChange | onInputChange / 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. |
| onOpenChange | onOpenChange / 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. |
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
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.
This component does not animate, so it has no reduced-motion behaviour to describe.