Overview
<Combobox /> is the canonical searchable-select primitive — a <Select /> with a typeable
input that filters its option list as the user types. <MultiCombobox /> is the same component
with multi-select semantics: selected values render as removable <Badge> chips before the
input, Backspace on an empty query removes the last chip.
Overview — searchable select with filtered options
Combobox sits at the intersection of three component families:
- Form-control — same
controlBase+variantColorMatrixshell as<Input />/<Textarea />/<Select />. Combobox is the fourth consumer of the shared matrix. - Overlay — reuses Select's listbox surface, item recipe, motion, and
usePosition(matchTriggerWidthmiddleware), so the dropdown reads as a sibling of Select / Menu / Popover. - List keyboard — reuses
_shared/useListKeyboard.ts, validated for a third time (Menu → Select → Combobox). The hook's filter-drivengetItems()accessor was designed precisely for this case where the visible item set changes on every keystroke.
When to use
- A picker bound to a long list (countries, tags, users) where typing-to-narrow saves clicks.
- A multi-select tag input — pass
creatable + onCreateOptionfor the canonical tag UX. - An autocomplete bound to a remote API — pass
loadOptionswith debouncing handled by the built-inuseDeferredFilter.
When NOT to use
- A short fixed list (< 8 options) with no search benefit → use
<Select />. - A free-form text input with hints (no commitment to a value) → use
<Input />with a separate suggestion popover. - A command launcher (actions, not values) → use the future
<CommandPalette />.
Anatomy
<Combobox|MultiCombobox
options=[…] // static
loadOptions={async (q) => …} // OR async — overrides static when both passed
value=… // string | null (single)
// string[] (multi)
onChange=…
inputValue=…
onInputValueChange=…
placeholder=…
variant size color // same vocab as Input / Select
matchStrategy="substring|startsWith|fuzzy|custom"
filterOption=(opt, query) => boolean // wins over matchStrategy
creatable + onCreateOption
clearable closeOnSelect openOnFocus
debounceMs loadingState
open defaultOpen onOpenChange placement matchTriggerWidth
renderOption renderEmpty renderLoading renderError renderCreateOption
disabled invalid required name
portalContainer translations
id aria-label aria-labelledby aria-describedby
className style sx
inputProps // pass-through to the underlying <input>
/><Combobox|MultiCombobox
options=[…] // static
loadOptions={async (q) => …} // OR async — overrides static when both passed
value=… // string | null (single)
// string[] (multi)
onChange=…
inputValue=…
onInputValueChange=…
placeholder=…
variant size color // same vocab as Input / Select
matchStrategy="substring|startsWith|fuzzy|custom"
filterOption=(opt, query) => boolean // wins over matchStrategy
creatable + onCreateOption
clearable closeOnSelect openOnFocus
debounceMs loadingState
open defaultOpen onOpenChange placement matchTriggerWidth
renderOption renderEmpty renderLoading renderError renderCreateOption
disabled invalid required name
portalContainer translations
id aria-label aria-labelledby aria-describedby
className style sx
inputProps // pass-through to the underlying <input>
/>- Single root element — Combobox is not a compound component; it uses a props-driven API
(
options,renderOption,filterOption,renderEmpty, …). - Two exports for type safety —
<Combobox>pinsvalue: string | null;<MultiCombobox>pinsvalue: string[]. Both render the same internal implementation; the only behavior delta is "close on select?" and "render tag chips?".
Variants
The input shell reuses Input / Textarea / Select's 4 × 7 form-control matrix verbatim — adding a
color or variant happens once in _shared/variantColorMatrix.ts and all four surfaces light up.
| Variant | Chrome |
|---|---|
outline | 1px border + paper background. Default. |
solid | bg-subtle resting; pops to paper on focus. |
ghost | Borderless at rest; gains border + tint on hover/focus. |
underline | Bottom rule only; minimal chrome. |
Sizes
| Size | Min-height | Padding-X | Font |
|---|---|---|---|
sm | min-h-8 | px-2 py-1 | text-sm |
md | min-h-10 | px-2.5 py-1 | text-sm |
lg | min-h-12 | px-3 py-1.5 | text-base |
min-h (not h) because multi-mode tags wrap onto multiple lines; the shell grows with content
to keep every chip visible.
Filter strategies
| Strategy | Predicate |
|---|---|
substring | Case-insensitive .includes(). Default. |
startsWith | Case-insensitive .startsWith(). |
fuzzy | Simple subsequence match (every query char appears in label in order). |
custom | Defers entirely to filterOption. Use when neither shortcut fits. |
Want ranked fuzzy (Fuse.js / fzf-style)? Bring your own ranker via filterOption — the bundled
fuzzy is intentionally yes-or-no with no scoring, because shipping a scorer would force a
ranking opinion that's almost always wrong for some consumer.
Async loading
<Combobox
loadOptions={async (query, { signal }) => {
const res = await fetch(`/api/users?q=${encodeURIComponent(query)}`, { signal });
if (!res.ok) throw new Error('Failed to load users');
return (await res.json()).map((u: User) => ({ value: u.id, label: u.name }));
}}
debounceMs={300}
/><Combobox
loadOptions={async (query, { signal }) => {
const res = await fetch(`/api/users?q=${encodeURIComponent(query)}`, { signal });
if (!res.ok) throw new Error('Failed to load users');
return (await res.json()).map((u: User) => ({ value: u.id, label: u.name }));
}}
debounceMs={300}
/>The bundled useDeferredFilter hook:
- Debounces query changes by
debounceMs(default 300). - Aborts the previous fetch via
AbortControllerso stale results never overwrite fresh ones. - Switches
loadingStatebetween'idle' | 'loading' | 'ready' | 'empty' | 'error'. - Cancels the in-flight controller on unmount so the promise resolves harmlessly.
The hook is exported publicly (useDeferredFilter) so future async-list components
(CommandPalette, search dropdowns) can reuse the exact lifecycle.
Creatable mode
<MultiCombobox
options={existingTags}
value={selectedTags}
onChange={setSelectedTags}
creatable
onCreateOption={(label) => ({ value: slugify(label), label })}
/><MultiCombobox
options={existingTags}
value={selectedTags}
onChange={setSelectedTags}
creatable
onCreateOption={(label) => ({ value: slugify(label), label })}
/>When creatable is on and the current query doesn't exactly match an existing option, a
+ Create "{query}" row appears at the tail of the list. Activating it (Enter / click) calls
onCreateOption(query) and adds the resulting option to the selection. Supports both sync and
Promise<Option> returns.
Form integration
Pass name and the component emits hidden <input type="hidden"> siblings — one for single
mode, one per selected value for multi mode (so form serialization yields name=a&name=b&name=c
exactly as a native <select multiple> would).
<form>
<Combobox name="country" required placeholder="Pick a country" options={…} />
<MultiCombobox name="skills" placeholder="Pick skills" options={…} />
</form><form>
<Combobox name="country" required placeholder="Pick a country" options={…} />
<MultiCombobox name="skills" placeholder="Pick skills" options={…} />
</form>Accessibility
W3C ARIA Combobox + Listbox pattern (APG 1.2):
- Input —
role="combobox"+aria-haspopup="listbox"+aria-expanded+aria-controls(only when open) +aria-autocomplete="list"+aria-activedescendantpointing to the currently-highlighted option id.aria-multiselectableis set on the listbox in multi mode. - Listbox —
role="listbox"+aria-labelledbythe trigger. - Options —
role="option"+aria-selected+aria-disabledfor disabled items +data-highlighted="true"for the keyboard-highlighted row. - Tags (multi) — rendered via
<Badge removable>; each remove button getsaria-label={t.removeTag(label)}. useFormFieldA11ybridgesid/aria-invalid/aria-required/aria-describedbyfrom the root onto the input — same hook Input / Textarea / Select use.
Keyboard
| Key | Action |
|---|---|
| Printable character | Update query + filter list (the input is the search field). |
| ArrowDown (closed) | Open + highlight first enabled item. |
| ArrowDown / ArrowUp (open) | Cycle highlight (wraps). |
| Home / End | First / last enabled item. |
| Enter / Space (open) | Select highlighted item. If creatable + no exact match → call onCreateOption(query). |
| Esc | Close without selecting. |
| Tab / Shift+Tab | Close + advance focus. |
| Backspace (multi, empty) | Remove the last selected tag. |
Type-ahead from useListKeyboard is intentionally disabled on Combobox — the input itself
is the search affordance, so intercepting printable keys would prevent the query from updating.
Filtering is the type-ahead, only better.
axe-core: 0 violations across single + multi, all four variants, and the disabled / invalid / required states.
Examples
Basic
Grouped
Async
Multi
MultiCreatable
CustomItem
HighlightMatches
FuzzyMatch
Variants
Sizes
Disabled
Invalid
FormSubmission
Controlled
Theming
defineTheme({
components: {
Combobox: {
defaultProps: { matchStrategy: 'fuzzy', clearable: false },
styleOverrides: {
wrapper: 'shadow-sm',
input: '',
content: 'shadow-xl',
item: 'rounded-md',
groupLabel: 'text-fg',
empty: '',
loading: '',
error: '',
createRow: 'text-primary',
clearButton: '',
},
},
},
});defineTheme({
components: {
Combobox: {
defaultProps: { matchStrategy: 'fuzzy', clearable: false },
styleOverrides: {
wrapper: 'shadow-sm',
input: '',
content: 'shadow-xl',
item: 'rounded-md',
groupLabel: 'text-fg',
empty: '',
loading: '',
error: '',
createRow: 'text-primary',
clearButton: '',
},
},
},
});Per-instance overrides via <Combobox className sx style /> merge on top of theme overrides,
which merge on top of the recipe — same precedence chain Input / Select use.
Headless layer
Three pure helpers + one hook are exported alongside the components:
| Export | Purpose |
|---|---|
flattenOptions(options) | Flatten nested (Option | Group)[] to parallel options + groupLabels. |
filterStrategies.substring | .startsWith | .fuzzy | Drop-in (label, query) => boolean predicates. |
fuzzyMatch(label, query) | The underlying yes/no subsequence-match function. |
highlightMatch(label, query, mark?) | Returns ReactNode with <mark>-wrapped matches. |
useDeferredFilter({ loadOptions, query, debounceMs, enabled }) | Async lifecycle hook. |
These are deliberately framework-light (no DOM, no React-only dependencies beyond
useDeferredFilter) so they can be reused outside Combobox.