Combobox
A text input that narrows a list the product supplies as the reader types, then lets them choose one match.
Preview
combobox · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/combobox
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { Combobox } from "@/components/ui/combobox"<Combobox
label="Medication"
placeholder="Search medications"
value={medication}
onValueChange={setMedication}
items={[
{ value: "amoxicillin", label: "Amoxicillin" },
{ value: "ibuprofen", label: "Ibuprofen" },
{ value: "paracetamol", label: "Paracetamol" },
]}
/>When to use it
Use it when
- One item from a list long enough that typing a few letters beats scrolling it: a city, a country, a product-owned medication name.
- A single choice where the reader knows roughly what they want and would rather type than hunt.
- A field that must stay compact, filtering a long list in place rather than pushing the form down.
Do not use it when
- A short fixed set that fits in a closed menu, so typing to filter buys nothing.Use
selectinstead. - Two to about five short options worth comparing at a glance.Use
radio-groupinstead.
Anatomy
ComboboxThe visible root: a neutral hairline box holding the input and the trigger.Controlled byitemsCombobox.InputThe text input and the one tab stop, role="combobox", named by label, filtering by contains match.Controlled bylabelCombobox.TriggerA trailing button that opens the popup without typing, a lucide ChevronsUpDown.Combobox.PopupThe floating list, portalled past overflow clipping, matched to the input's width.Combobox.ItemOne match, role="option" with aria-selected. The active row takes the neutral hover surface.Controlled byvalueCombobox.ItemIndicatorThe lucide Check on the chosen match, so the selection survives greyscale.Combobox.EmptyThe muted line shown when nothing matches. Base UI keeps it mounted and announces it politely.Controlled byemptyMessage
- Combobox 1
- Combobox.Input 1
- Combobox.Trigger 1
- Combobox.Popup 1
- Combobox.Empty 1
- Combobox.Item 0..n
- Combobox.ItemIndicator 1
Examples
Filtering a list
items supplies a fictional set of cities and typing narrows it. value starts
on one city, so the input is filled and the tick is shown.
combobox-filtering-a-list · base base · style base-lyraOpen under the product themeNo match
Type something the list does not contain, such as "xyz". emptyMessage fills the
popup, announced politely.
combobox-no-match · base base · style base-lyraOpen under the product themeContent guidelines
Name the field for the thing chosen, "Medication" rather than "Search", since a
screen-reader user hears the name before the role. Write emptyMessage to help,
such as "No matches. Check the spelling." Spell item labels as the product's list
spells them.
Do
A label of "Medication", a placeholder of "Search medications". The field names the thing, the prompt the act.
Don’t
A label of "Search" and a built-in list of medicines. opsinjs ships no vocabulary.
Accessibility
Audited against WCAG 2.2 AA in a source pass and a rendered pass, author-run rather than independent. Clinical review is pending.
The audit fixed two defects in the source: the trigger button now takes an
aria-label built from the field's label, and the input's stray outline-none
no longer hides the keyboard focus ring.
- Base UI renders
role="combobox"on the input,role="listbox"on the popup androle="option"witharia-selectedon each match. - The input is the single tab stop; Arrow keys move a highlight through the popup.
- The selection is a lucide Check and the highlight a neutral surface under
data-highlighted, so both survive greyscale. - The input and the trigger floor a 44pt target in rem and carry their own focus ring.
Known gaps. The label reaches a screen reader as aria-label, not a visible
label element; a shared repair across the other input wrappers is still open.
The live match count is not announced as the reader filters. No contrast pair is
measured, so the report below stands in.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves focus into the control, onto the input | The one tab stop. A second Tab reaches the trigger. |
| Typing | Filters the list to the matches | Contains match on the label. The popup opens as you type. |
| Arrow Down | Opens the popup and moves the highlight into the list | Opens a closed popup and highlights the first match. |
| Arrow Up, Arrow Down | Moves the highlight between matches | The popup holds the highlight while the input keeps the caret. |
| Enter | Chooses the highlighted match and closes the popup | The label fills the input and onValueChange reports the value. |
| Escape | Closes the popup without choosing | The input keeps its text. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
| body text on the canvas material | light | 104.7 | 17.96:1 | Pass |
| body text on the canvas material | dark | -100.5 | 18.00:1 | Pass |
| body text on the inset material | light | 98.1 | 16.32:1 | Pass |
| body text on the inset material | dark | -100.1 | 17.18:1 | Pass |
| body text on the card material | light | 104.7 | 17.96:1 | Pass |
| body text on the card material | dark | -99.6 | 16.32:1 | Pass |
| body text on the raised material | light | 104.7 | 17.96:1 | Pass |
| body text on the raised material | dark | -97.5 | 13.77:1 | Pass |
| body text on the sheet material over the darkest backdrop | light | 99.9 | 16.76:1 | Pass |
| body text on the sheet material over the darkest backdrop | dark | -99.7 | 16.47:1 | Pass |
| body text on the sheet material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the sheet material over the lightest backdrop | dark | -80.8 | 6.07:1 | Pass |
| body text on the overlay material over the darkest backdrop | light | 85.9 | 13.48:1 | Pass |
| body text on the overlay material over the darkest backdrop | dark | -99.9 | 16.77:1 | Pass |
| body text on the overlay material over the lightest backdrop | light | 104.7 | 17.96:1 | Pass |
| body text on the overlay material over the lightest backdrop | dark | -54.2 | 2.69:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from ComboboxProps in registry/bases/base/combobox.tsx.
value and onValueChange make this controlled, with no selection state of its
own. A value matching no item renders nothing chosen and raises a development
warning. label is required, and items comes from the product. Omitted,
emptyMessage falls back to a plain line.
Related
- Select opens a closed menu of a fixed list with no text filter.
- RadioGroup shows two to five options side by side.
- Field is the labelled wrapper a Combobox sits inside.