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

Sea×SeattleSearch EnginesSeafood RecipesMULTI-SELECT VARIANTReact×TypeScript×SINGLE-SELECT WITH SEARCH

Modes

Modevalue typeWhen to use
Single selectstringOne value chosen from a long list (> 7 options). Prefer a native <select> for short lists.
Multi-selectstring[]Multiple values from the same list — tags, permissions, categories.
Async searchstringOptions fetched from the server on each keystroke — users, locations, large datasets.
Free text + selectstringUser can type a value that doesn't exist in the list (email CC field, custom tags).

Keyboard Navigation

KeyAction
↓ / ↑Move focus through options
Select focused option; confirm free-text entry
EscClose dropdown without selecting; clear query if already closed
TabClose dropdown and move to next field
BackspaceIn multi-select: remove the last chip when the input is empty
⌘AIn 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

PropTypeDefaultDescription
optionsArray<{ value: string; label: string; group?: string }>The full list of selectable options. Filtered client-side by default; pass onSearch to filter server-side.
valuestring | string[]Controlled selected value. Pass an array to enable multi-select mode.
onChange(value: string | string[]) => voidCalled when the selection changes.
placeholderstring"Select…"Placeholder shown in the trigger when no value is selected.
onSearch(query: string) => voidundefinedWhen provided, disables client-side filtering and calls this function on every keystroke so you can fetch server-side results.
loadingbooleanfalseShows a spinner inside the dropdown while onSearch results are loading.
maxSelectednumberIn multi-select mode, caps the number of selected items. Disables remaining options when the cap is reached.
clearablebooleantrueShows an × button to clear the selection.
disabledbooleanfalsePrevents 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.