Forms
Combobox
A select you can type into.
Usage
When to reach for it
import { Combobox } from '@misoto22/design'Notes
A select you can type into, choosing one or several.
The line against Select is length, and it is not a matter of taste: a styled select is better up to roughly a dozen options, because a list nobody can filter is faster to scan than one they have to think about. Past that, this is the right answer.
Filtering, the highlighted row and the arrow keys come from cmdk, which implements the ARIA combobox pattern properly: the highlight moves through aria-activedescendant while focus stays in the input. Hand-rolled comboboxes move focus into the list instead, and the typed text stops being editable.
Controlled or uncontrolled, like every other form control here.
Anatomy
| Element | Description |
|---|---|
| Triggerrequired | A <button role="combobox"> named by the label and its own summary together, carrying aria-expanded. Its text truncates rather than wrapping, so the field keeps its height whatever is chosen. |
| Summary | The trigger’s text: the placeholder, or up to two chosen labels joined by commas, then “n selected”. Counting past two is what stops a multiple picker reflowing the form on every choice. |
| Clear | A <span role="button"> beside the chevron, on multiple with something chosen. A span rather than a nested <button>, which is invalid inside the trigger and which browsers reparent out of the field. |
| Filter field | cmdk’s input inside the panel. It is named through the Command wrapper as “label: searchPlaceholder”, because aria-labelledby beats aria-label and naming the input directly did nothing. |
| Option row | A tick for single, a fillable box for multiple, then the label. emptyMessage takes the list’s place when the filter matches nothing. |
Best practices
Do
- Spell “nothing chosen” as an empty string when the value is controlled. value={undefined} is precisely how this component decides it is UNCONTROLLED, so clearing that way hands it back its own state and it stops following the parent.
- Put readable text in label and anything else worth matching in keywords: cmdk scores against the option’s value too, so a list keyed by UUID is being ranked on a string no reader will ever type.
- Say what WOULD match in emptyMessage. The default tells a reader the filter ran and nothing about which of the four hundred options they should have typed instead.
Don’t
- Past two choices the trigger stops naming them — it announces “Tags, 3 selected”, and WHICH three is only in the panel. Print them beside the field when the choice has to be checkable without opening it.
- Do not hand it thousands of options. Nothing here virtualises: every option in the array renders into the panel on open and stays there behind the filter, so the list length is a DOM cost, not a search cost.
- A disabled option is not a hidden one — it still renders and still matches the filter, so a reader can type its exact name, watch it come up, and be unable to pick it with no reason offered.
Examples
default
Seven bodies behind a filter that matches more than it prints: type compact and two come up though neither says the word. Put readable text in label and anything else worth matching in keywords — cmdk scores against the option's value as well, so a list keyed by UUID is being ranked on a string no reader will ever type. The highlight moves through aria-activedescendant while focus stays in the input, which is why the typed text stays editable. Nothing virtualises here: every option renders on open and stays there behind the filter, so a thousand of them is a DOM cost, not a search cost.
Type to filter — “compact” matches two of them.
multiple
Several at once, with the trigger summarising rather than listing: two labels, then a count. That threshold is what stops the field growing with its value and reflowing the form on every pick. The panel stays open while you choose, and the clear control beside the chevron is a span with a button role — a real nested button inside the trigger is invalid markup that browsers reparent out of the field entirely. The trigger is named by its label and its summary together — aria-labelledby pointing at both — so a screen reader hears “Tags 3 selected” rather than only the label, and the count is confirmable without opening the panel.
The panel stays open while you pick; past two it counts instead of listing.
controlled and cleared
A controlled Combobox, and the one correct way to empty it. Spell “nothing chosen” as an empty string: value={undefined} is precisely how this component decides it is UNCONTROLLED, so clearing that way hands it back its own state and it stops following the parent from then on — the bug reads as a picker that ignores every reset after the first. The readout below is the state, which is the point of holding it out here.
value is an empty string
Keyboard
| Key | Does |
|---|---|
| EnterSpace↓ | Opens the list. |
| ↑↓ | Moves the highlight while focus stays in the filter. |
| Enter | Chooses the highlighted option; choosing the current one clears it. |
| Escape | Closes without choosing. |
Accessibility
- The highlight moves through aria-activedescendant while focus stays in the input — the ARIA combobox pattern. Hand-rolled comboboxes move focus into the list, and the typed text stops being editable.
- label is required, and it is announced with the summary rather than instead of it: the trigger reads “Tags, 3 selected”. Inside a Field the FIELD’s label supplies the name half and the label prop is neither rendered nor announced on the trigger — it still names the clear control, as “Clear Tags”, so it has to stay truthful even where the trigger no longer says it.