Combobox
A text input with an attached, type-to-filter listbox. Implements the
WAI-ARIA combobox pattern — the input owns focus, the listbox is
referenced via aria-controls, and the highlighted option moves via
aria-activedescendant rather than focus.
Selection and the visible query are independent state: selecting an option syncs the query to the option's label so the user sees what was picked, but subsequent typing reopens the list and re-filters without losing the selection until a new one is committed.
The data shape#
options is an array where each entry is either a plain string (for
the simple case) or an object with a value and optional label /
description / disabled.
<Combobox
options={['apple', 'banana', 'cherry']}
/>
<Combobox
options={[
{ value: 'us', label: 'United States', description: 'USD' },
{ value: 'jp', label: 'Japan', description: 'JPY', disabled: true },
]}
/>String entries are normalized internally to { value, label: value }.
Custom filtering operates on the NormalizedOption shape — see below.
Default#
The default example demonstrates the simple string-array shape. Type to filter; arrow keys navigate the list; Enter or click commits the selection and syncs the input.
Rich options#
When you need a secondary description line, a disabled state, or a JSX label, pass option objects. The dropdown renders the label in the row's primary line and the description (if present) in a muted secondary line.
Option (ComboboxOption)#
A union of two shapes — strings get a sane default; objects unlock descriptions and disabled state.
string— shorthand for{ value: string, label: string }. The string is used as both the committed value and the visible label.{ value: string, label?: ReactNode, description?: ReactNode, disabled?: boolean }— the rich shape:value(required) — the string committed toonValueChangeand stored asvalue.label— visible primary line. Defaults tovaluewhen omitted. Accepts JSX for icons / formatting.description— small muted secondary line under the label.disabled— keeps the row in the list but un-selectable (skipped by keyboard navigation, no click handler).
Custom filter (NormalizedOption)#
Pass filter={(option, query) => …} to replace the default
case-insensitive substring match. The first argument is the
normalized option — the union has been collapsed and a
searchText field has been precomputed so you don't have to lowercase
or interpolate label/description on every keystroke.
value— the option's commit value.label— the option's visible label (ReactNode).description— the option's description (ReactNode, if any).searchText— lowercaselabel + " " + description, pre-joined for substring matching.disabled— whether the option should be skipped.
Selection vs query state#
The two are independent and each can be controlled or uncontrolled:
- Selection —
value+onValueChange(controlled), ordefaultValue(uncontrolled). Fires when a row is clicked or Enter commits the highlighted row. - Query —
query+onQueryChange(controlled), ordefaultQuery(uncontrolled). Fires on every keystroke.
When a selection is committed, the query is synced to the selected option's label so the input reflects the choice; the user can then type to change it without immediately losing the prior selection.
Component props#
| Prop | Type | Default | Description |
|---|---|---|---|
| options * | readonly ComboboxOption[] | — | Available options. Strings are normalized to `{ value, label: value }`. |
| value | string | undefined | — | Controlled selected option value. |
| defaultValue | string | undefined | — | Default selected value (uncontrolled). |
| onValueChange | ((value: string) => void) | undefined | — | Fires with the option's `value` when a selection is committed. |
| query | string | undefined | — | Controlled query. |
| defaultQuery | string | undefined | — | Default query (uncontrolled). |
| onQueryChange | ((query: string) => void) | undefined | — | Fires whenever the query changes. |
| placeholder | string | undefined | — | |
| filter | ((option: NormalizedOption, query: string) => boolean) | undefined | (option: NormalizedOption, query: string) =>
option.searchText.includes(query.toLowerCase()) | Custom matcher. Default: case-insensitive substring on label/description. |
| emptyState | ReactNode | — | Empty-state node rendered when filtering yields nothing. |
| width | string | number | undefined | 260 | Pixel or CSS width of the wrapper. Default 260. |
| disabled | boolean | undefined | — | |
| name | string | undefined | — | |
| id | string | undefined | — | |
| aria-label | string | undefined | — |