Combobox
Filterable single-select combobox with a virtualized listbox popup.
import { Combobox } from '@elirobinson/react/components/organisms/Combobox';Styles: @elirobinson/react/styles/organisms/Combobox.css@elirobinson/react/styles/organisms/VirtualList.css — already included when you import @elirobinson/react/styles.css.
Show code
import { useState } from 'react';
import { Combobox } from '@elirobinson/react/components/organisms/Combobox';
const projects = [
{ label: 'Kids Recipes', value: 'kids-recipes' },
{ label: 'Interactive Maths', value: 'interactive-maths' },
{ label: 'Coaching Guides', value: 'coaching-guides' },
{ label: 'Miltinson marketing site', value: 'marketing-site' },
{ label: 'AI consulting intake', value: 'ai-consulting' },
{ label: 'Tech support queue', value: 'tech-support' },
];
export default function Basic() {
const [value, setValue] = useState<string | undefined>();
return (
<Combobox
label="Project"
options={projects}
value={value}
onValueChange={setValue}
className="demo-col"
/>
);
}When to use it
Use Combobox when the option set is long enough that typing to filter beats scanning or
scrolling. It's a single-select, filterable field — type a few characters and the list narrows
to matches; arrow through them and pick one.
For a short, fixed list (ten or so options), a plain Select is less to build and there's
nothing worth filtering. And this isn't a general autocomplete-anything field: it only accepts
one of the options you pass in, not arbitrary free text.
Filtering a long list
The listbox is virtualized — it renders only the rows currently in view, not every option in the array, so a list of hundreds stays as responsive as a list of ten. Try typing "drill 3" or arrowing all the way to the bottom.
Show code
import { useState } from 'react';
import { Combobox } from '@elirobinson/react/components/organisms/Combobox';
const sports = [
'Soccer',
'Basketball',
'Baseball',
'Softball',
'Track',
'Swimming',
'Volleyball',
'Tennis',
'Golf',
'Cross country',
'Wrestling',
'Lacrosse',
'Field hockey',
'Rugby',
];
const drills = sports.flatMap((sport) =>
Array.from({ length: 40 }, (_, index) => ({
label: `${sport} — drill ${index + 1}`,
value: `${sport.toLowerCase().replace(/\s+/g, '-')}-${index + 1}`,
})),
);
export default function LongList() {
const [value, setValue] = useState<string | undefined>();
return (
<Combobox
label="Drill"
options={drills}
value={value}
onValueChange={setValue}
className="demo-col"
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | Accessible label for the combobox input (also its accessible name). |
onValueChangerequired | (value: string) => void | — | — |
optionsrequired | ComboboxOption[] | — | — |
className | string | — | — |
value | string | — | — |
Accessibility
- Implements the WAI-ARIA combobox-with-listbox pattern using
aria-activedescendant, not the focus-moves-into-the-list model. The input carriesrole="combobox",aria-haspopup="listbox",aria-expanded,aria-controls(the listbox id),aria-autocomplete="list", andaria-activedescendantpointing at whichever option is currently highlighted. - DOM focus never leaves the input — the highlighted option is communicated purely by id
reference. Options themselves are
tabIndex={-1}and useonMouseDownpreventDefault so clicking one never steals focus away from the input. - Keyboard:
ArrowDown/ArrowUpopen the list if it's closed, or move the highlight by one option, clamped at both ends — the highlight stops at the first/last option rather than wrapping around.Enterselects the currently highlighted option (not always the first) and closes the list.Escapecloses the list without changing the selected value. Typing any character re-opens the list if it was closed. - Clicking outside the control closes the list (the outside-click listener is only attached while it's open).
- A query with no matches renders a labeled, empty listbox with "No matches" text rather than
nothing at all —
aria-controlsalways resolves to a real element, open or empty. - The highlighted option is scrolled into view automatically as it moves past the edge of the virtualized window, so keyboard navigation never highlights something that's scrolled out of sight.
Do
- Give every option a stable value distinct from its label.
- Pair it with a real accessible name via the required label prop.
- Trust the built-in virtualization for long option lists instead of pre-slicing options yourself.
- Keyboard-test Arrow/Enter/Escape, not just click-through — the highlight model depends on it.
Don't
- Move DOM focus into the listbox yourself — the whole pattern depends on focus staying on the input.
- Pass duplicate values across options — the active-option lookup assumes one match.
- Use Combobox for a short, fixed choice — reach for Select when there's nothing to filter.
- Rely on hover alone to show the current highlight — always verify with the keyboard.