Combobox
A searchable Select — typing narrows the options to those matching what has been typed, rather than only ever picking from the full list.
Usage#
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxTriggerIcon,
} from "neelam-ui";
<Combobox>
<ComboboxInput placeholder="Search fruit…" />
<ComboboxContent>
<ComboboxItem value="apple">Apple</ComboboxItem>
<ComboboxItem value="banana">Banana</ComboboxItem>
<ComboboxEmpty>No fruit found.</ComboboxEmpty>
</ComboboxContent>
</Combobox>ComboboxEmpty renders only when filtering has excluded everything. Include it —
without it, a search with no matches collapses to an empty box that gives the
user no feedback at all.
Labelling#
ComboboxInput is a text input and needs a label like any other. Wrapping it in
a <label> is the most robust option, since it survives the input being
re-parented:
<label className="flex w-64 flex-col gap-1.5 text-sm font-medium">
Fruit
<Combobox>…</Combobox>
</label>Placeholder is not a label
A placeholder disappears the moment the user types — exactly when they most need to remember what the field is for — and is not reliably announced. Use it for an example value, never as the field's name.
Controlled#
const [value, setValue] = useState("apple");
<Combobox value={value} onValueChange={setValue}>
…
</Combobox>Uncontrolled use with defaultValue is supported the same way.
Combobox or Select?#
Combobox | Select | |
|---|---|---|
| Options | Many; filtering earns its keep past ~10 | Few enough to scan |
| Input | Free text, filters the list | Typeahead jumps to a match |
| Best for | Countries, users, repositories | Status, role, sort order |
If the list fits on screen without scrolling, Select is the lighter and more
predictable choice.
Keyboard#
| Key | Behaviour |
|---|---|
| ↓ | Opens the list, or moves to the next option. |
| ↑ | Moves to the previous option. |
| Enter | Selects the active option and closes the list. |
| Escape | Closes the list, leaving the value unchanged. |
| A–Z | Typing filters the list; the first match becomes active. |
Accessibility#
Implements the APG combobox pattern:
- The input carries
role="combobox"witharia-expandedandaria-controlspointing at the listbox. - The active option is tracked with
aria-activedescendant, so focus stays in the text input while the arrow keys move the selection — a screen reader user can keep typing without losing their place. - Options carry
role="option"witharia-selected. - The list closes on
Escapewithout committing a value, so an accidental open is always recoverable.
Why aria-activedescendant rather than roving focus
Moving real DOM focus into the list would take it out of the text input, and
the user could no longer type to refine the search. aria-activedescendant is
what the pattern specifies for exactly this reason.
API reference#
Combobox#
| Prop | Type | Default |
|---|---|---|
children | ReactNode | — |
defaultValue | string | — |
disabled | boolean | — |
onValueChange | ((value: string) => void) | — |
value | string | — |
ComboboxInput#
| Prop | Type | Default |
|---|---|---|
defaultValue | string | — |
disabled | boolean | — |
onValueChange | ((value: string) => void) | — |
value | string | — |
ComboboxItem#
| Prop | Type | Default |
|---|---|---|
childrenrequiredThe visible label — a plain string, not arbitrary `children` the way `SelectItem` allows. Filtering has to know an item's text *before* deciding whether to render it at all, which only works against a string known up front, not something derived from rendered JSX after the fact. See `DECISIONS.md`. | string | — |
valuerequired | string | — |