Components · Combobox
Combobox
Search, then choose. Downshift owns the WAI-ARIA combobox contract — active-descendant bookkeeping, roving highlight, Home/End, typeahead, Escape and Enter. LUC owns the field, the menu, the grouped rows and the loading and no-results states. Nothing here fetches anything.
import { Combobox } from "@luc/ui";Examples
Searchable selection
The default matcher is a case-insensitive contains across the label and the description.
Type a ticker or a name.
Selected: nothing yet
Grouped by region, filtered as you type. It starts with a chosen value, and opening it still offers every country — the field shows the selection, the menu is not filtered by it.
Disabled keeps its value readable.
An empty result is a state, not a blank popover.
<Combobox options={instruments} aria-label="Instrument" value={instrument} onValueChange={setInstrument} />Sizes and the clear affordance
One size token drives the field, the glyph padding and the row rhythm. The clear button is the default and can be turned off when the value may never be unset.
A value the form requires: the clear affordance would offer an invalid state, so it is gone and the value can only be replaced.
<Combobox options={instruments} size="sm" aria-label="Small" />
<Combobox options={instruments} size="md" aria-label="Default" />
<Combobox options={instruments} size="lg" aria-label="Large" />
<Combobox options={instruments} clearable={false} placeholder="Choose an instrument…" aria-label="Pinned instrument" value={pinned} onValueChange={setPinned} />Async-friendly, without owning the network
Pass inputValue, onInputValueChange and loading, and hand back options when your request resolves. The component never calls fetch.
240 prepared series, filtered by the caller.
<Combobox options={results} loading={loading} inputValue={query} onInputValueChange={(next) => search(next)} aria-label="Series" />Usage
import { Combobox } from "@luc/ui";<Combobox options={instruments} value={instrument} onValueChange={setInstrument} aria-label="Instrument" />`options` is the list and `value` is the whole selected option rather than its id. Nothing here fetches: pass `loading` and hand back options from a request you own.
API
Read from the published TypeScript API, so a prop that is not listed here does not exist and a default that is listed is the one the compiler sees.
| Export | Kind | Signature |
|---|---|---|
Combobox | 21 documented props | ({ options, value, onValueChange, inputValue, onInputValueChange, filter, placeholder, emptyMessage, loading, disabled, clearable, clearLabel, openLabel, closeLabel, size, id, name, className, maxHei… |
Combobox props
Type: ComboboxProps
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
aria-label? | string | undefined | — | Accessible name for the input. Required unless a `Field` label is present. |
className? | string | undefined | — | |
clearable? | boolean | undefined | — | |
clearLabel? | string | undefined | — | Accessible names for the three built-in buttons. Defaults are English; a product in another language passes its own strings rather than forking the component. The open/close pair … |
closeLabel? | string | undefined | — | |
disabled? | boolean | undefined | — | |
emptyMessage? | ReactNode | — | Announcement + body when nothing matches. |
filter? | ((option: ComboboxOption, query: string) => boolean) | undefined | — | Replacement matcher. Default: case-insensitive `label`/`description` contains. |
id? | string | undefined | — | |
inputValue? | string | undefined | — | Query text (controlled) — the async path. |
loading? | boolean | undefined | — | |
maxHeight? | string | undefined | — | Max height of the scrollable list, e.g. `18rem`. |
name? | string | undefined | — | |
onInputValueChange? | ((query: string) => void) | undefined | — | |
onValueChange? | ((option: ComboboxOption | null) => void) | undefined | — | |
openLabel? | string | undefined | — | |
options | readonly ComboboxOption[] | — | |
placeholder? | string | undefined | — | |
renderOption? | ((option: ComboboxOption, state: { selected: boolean; highlighted: boolean; }) => ReactNo… | — | Render a distinct row (icon, avatar…). Falls back to the default row. |
size? | ControlSize | undefined | — | |
value? | ComboboxOption | null | undefined | — | Selected option (controlled). |
Guidelines
Do
- Use it when the reader has to search to find the value; a handful of options they recognise on sight is `Select`.
- Pass `inputValue` and `onInputValueChange` when the list comes from a request, and keep that request in your own code.
- Write `emptyMessage` as a sentence about the query — an empty result is a state the reader has to be told about.
Don't
- Wait for the empty state to explain a failed request: `emptyMessage` says nothing matched, `loading` says work is in flight.
- Filter a large remote list in the browser because the control feels instant; `filter` is a matcher for the options you already hold.
- Assume the clear button can be removed for tidiness — set `clearable={false}` only when the value may never be unset.
Accessibility
The input keeps `role="combobox"` with `aria-expanded` and `aria-activedescendant`, and the list is a `role="listbox"` whose rows are `role="option"`. Arrow Down and Arrow Up move the highlight without moving focus, Enter selects what is highlighted, Escape closes without changing the value, and Tab away closes the list instead of leaving it floating. The three built-in buttons take `clearLabel`, `openLabel` and `closeLabel` so a page in another language can name them.
Related