Combobox
A text input that filters a list and remembers what was chosen — for lists too long to scroll.
Installation
npx shadcn@latest add https://qure-ui.qure.ai/r/combobox.jsonUsage
import {
Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput,
ComboboxInputGroup, ComboboxItem, ComboboxList, ComboboxTrigger,
} from '@/components/ui/combobox'const protocols = ['CT chest, high resolution', 'CT head, non-contrast']
<Combobox items={protocols}>
<ComboboxInputGroup>
<ComboboxInput placeholder="Search protocols" />
<ComboboxTrigger />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxEmpty>No protocol matches that.</ComboboxEmpty>
<ComboboxList>
{(protocol: string) => (
<ComboboxItem key={protocol} value={protocol}>{protocol}</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>items is not decoration. It is the array the root filters, and it is what ComboboxEmpty
counts to decide whether the list is empty. A combobox written with static ComboboxItem
children and no items renders every option no matter what is typed — nothing errors, the
filter simply does not exist. Drive the list from items and a function child, as above.
Reach for a combobox when the list is long enough that scrolling it is worse than typing at it —
protocols, referrers, sites, ICD codes. Under about a dozen options a Select is faster, because
the reader can see all of them without committing to a search term. When any text at all is
valid and the list is only a suggestion, you want Autocomplete; when the list is a set of
actions rather than values, you want Command.
Composition
<Combobox> {/* owns the value, the query and the open state */}
<ComboboxInputGroup> {/* the field shell — carries the border */}
<ComboboxInput> {/* the query; bare inside the group */}
<ComboboxClear> {/* appears once there is something to clear */}
<ComboboxTrigger> {/* the chevron, opens without typing */}
<ComboboxChips> {/* multi-select only; the input goes inside it */}
<ComboboxChip>
</ComboboxInputGroup>
<ComboboxContent> {/* portal + positioner + popup */}
<ComboboxStatus> {/* "Searching…", announced politely */}
<ComboboxEmpty> {/* "No match", announced politely */}
<ComboboxList> {/* the rows */}
<ComboboxGroup>
<ComboboxGroupLabel>
<ComboboxCollection>
<ComboboxItem> {/* renders its own tick */}
</ComboboxContent>
</Combobox>ComboboxContent bundles the portal, the positioner and the popup, but deliberately not the
list: ComboboxEmpty and ComboboxStatus are siblings of the list, not children of it. Both are
live regions and have to stay mounted to be announced, so conditionally render the text inside
them rather than the elements themselves.
Choosing several
1 selected
multiple turns the value into an array and each row into a toggle. The chips are the Base UI
anatomy rather than ours: ComboboxChips lives inside ComboboxInputGroup, and the input lives
inside the chips, because the caret has to end up after the last chip when the field wraps.
Give each chip an aria-label — the remove button next to it otherwise announces only "Remove".
Groups
Grouped items are { value, items }. The root recognises the shape, filters within each group
and drops the groups that end up empty, so searching "spine" leaves one heading rather than three
with a gap under two of them.
Searching a server
Pass filter={null} when the results already came back filtered. Leaving the local matcher on
runs it a second time over the server's answer, and a server that fuzzily matched "meta" to
"Mehta" has that work thrown away.
ComboboxStatus is the right place for "Searching…" — it is a polite live region, so a screen
reader hears the wait rather than being left with an unexplained silence.
Sizes
The 32/40/48 field scale, shared with Input and Select, so a combobox and a text field on the
same row line up. Pass size to ComboboxInput; the group takes its height from whatever input
is inside it.
How state reads
Read off the rendered DOM rather than assumed:
.cn-combobox-item[data-highlighted] { background-color: var(--background-brand-tertiary); }
.cn-combobox-item[data-selected] { font-weight: 600; }data-highlighted is the cursor — keyboard and pointer both, kept to one row at a time by the
primitive. data-selected is the chosen value and survives the popup closing. The trigger gets
data-popup-open, which is what rotates the chevron.
Keyboard
Observed, not assumed: typing filters and opens the list; ArrowDown from the input moves into it and wraps back to the input past the last row; Enter takes the highlighted row; Escape closes the list and leaves the value alone; Backspace in an empty multi-select input removes the last chip.
The chevron is a real ComboboxTrigger, not a decorative icon, so the list can be opened
without typing. A reader who does not know the vocabulary needs to browse before they can
search.
API Reference
Everything Base UI's Combobox accepts. What we
add on ComboboxInput:
Prop
Type
On ComboboxContent:
Prop
Type
On Combobox itself, the ones you will actually reach for:
Prop
Type