Combobox
Searchable select with keyboard navigation, multi-select, grouped options, and async server-side filtering. Drop-in replacement for <select> wherever search is needed.
Preview
Modes
| Mode | value type | When to use |
|---|---|---|
| Single select | string | One value chosen from a long list (> 7 options). Prefer a native <select> for short lists. |
| Multi-select | string[] | Multiple values from the same list — tags, permissions, categories. |
| Async search | string | Options fetched from the server on each keystroke — users, locations, large datasets. |
| Free text + select | string | User can type a value that doesn't exist in the list (email CC field, custom tags). |
Keyboard Navigation
| Key | Action |
|---|---|
↓ / ↑ | Move focus through options |
↵ | Select focused option; confirm free-text entry |
Esc | Close dropdown without selecting; clear query if already closed |
Tab | Close dropdown and move to next field |
Backspace | In multi-select: remove the last chip when the input is empty |
⌘A | In multi-select: select all visible options |
Implementation
Combobox.tsx
tsx
import { Combobox } from "@/components/ui/Combobox";
const LANGUAGES = [
{ value: "ts", label: "TypeScript" },
{ value: "rs", label: "Rust" },
{ value: "go", label: "Go" },
{ value: "py", label: "Python" },
{ value: "swift", label: "Swift" },
];
// Single select
const [lang, setLang] = useState("");
<Combobox
options={LANGUAGES}
value={lang}
onChange={setLang}
placeholder="Select a language…"
/>
// Multi-select
const [langs, setLangs] = useState<string[]>([]);
<Combobox
options={LANGUAGES}
value={langs}
onChange={setLangs}
placeholder="Pick languages…"
maxSelected={3}
/>
// Server-side search
const [results, setResults] = useState(LANGUAGES);
const [loading, setLoading] = useState(false);
async function handleSearch(query: string) {
setLoading(true);
const data = await fetchLanguages(query);
setResults(data);
setLoading(false);
}
<Combobox
options={results}
value={lang}
onChange={setLang}
onSearch={handleSearch}
loading={loading}
placeholder="Search languages…"
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
options | Array<{ value: string; label: string; group?: string }> | — | The full list of selectable options. Filtered client-side by default; pass onSearch to filter server-side. |
value | string | string[] | — | Controlled selected value. Pass an array to enable multi-select mode. |
onChange | (value: string | string[]) => void | — | Called when the selection changes. |
placeholder | string | "Select…" | Placeholder shown in the trigger when no value is selected. |
onSearch | (query: string) => void | undefined | When provided, disables client-side filtering and calls this function on every keystroke so you can fetch server-side results. |
loading | boolean | false | Shows a spinner inside the dropdown while onSearch results are loading. |
maxSelected | number | — | In multi-select mode, caps the number of selected items. Disables remaining options when the cap is reached. |
clearable | boolean | true | Shows an × button to clear the selection. |
disabled | boolean | false | Prevents interaction and dims the control. |
Accessibility
- →The input has role="combobox", aria-expanded, aria-haspopup="listbox", aria-autocomplete="list", and aria-controls pointing to the listbox.
- →Each option has role="option". The focused option is tracked via aria-activedescendant on the input, not a moved focus.
- →Selected options in multi-select have aria-selected="true". Chips include a visually-hidden "Remove" label for screen readers.
- →When async results load, announce the count to screen readers via a live region: '5 options available'.
- →The dropdown is portaled to document.body so it's never clipped by overflow:hidden ancestors.