Dev view
Combobox
A text input combined with a popup list of suggestions. The user types to filter, navigates with arrow keys, and selects an option to commit a value. Distinct from a Select (which has no text input) and from Autocomplete (which is a behavior, not a component name in most design systems).
When to use
Use
When the user selects a value from a finite or near-finite set that benefits from typeahead filtering. The text-input front differentiates it from `Select`: users can type to narrow before committing. Best for option counts of ~10–10,000 with optional async filter.
Avoid
For free-text input with no constrained set of values — that is `Input` or `SearchInput`. For a fixed short list (≤7) where typeahead adds no value — `Select`. For multi-tag input where the set is open-ended and tags are user-created — `TagInput`.
Versus related
- select
`Select` has no text-input front; the user picks from a fixed list via click or keyboard. `Combobox` adds a typeahead input plus async and strict modes. Migrate from Select to Combobox when the option count crosses the "scrollable popup is annoying" threshold (~10 items in practice).
- search-input
`SearchInput` is free text submitted to a search engine; results appear separately. `Combobox` constrains output to a known set of options and commits on selection. They look similar; the distinguisher is whether the popup is a completion list (Combobox) or a results preview (SearchInput).
- tag-input
`TagInput` accepts user-created tags or selections from a set and renders accumulated tags inline. `Combobox` with `variant: multi-select` is the constrained-set version; `TagInput` is the open-set version where users can create new tags.
Code anatomy
| Slot | Code slot | Semantic |
|---|---|---|
input | input | textbox |
clear-button | clear | button |
trigger-button | trigger | button |
listbox | listbox | listbox |
option | option | option |
empty-state | empty | status |
Variants, properties, states
Variants
Structurally different versions of the component.
single-selectmulti-selectcreatable Properties
The same component, parameterised.
| Property | Type |
|---|---|
filterMode | startsWith | contains | fuzzy | none |
strict | boolean |
virtualised | boolean |
async | boolean |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactivedisabled |
data | closedopenbusyinvalid |
State transitions
| From | To | Trigger |
|---|---|---|
closed | open | User types a printable character into the input, presses Down/Up arrow with the popup closed, or clicks the trigger button. `aria-expanded` flips to `true`; the listbox renders. |
open | closed | User presses Escape with the popup open, clicks outside the combobox, or selects an option (single-select). `aria-expanded` flips to `false`; the listbox is removed. |
open | busy | Typed input triggers an async filter request (`async: true`). Existing options are replaced with a loading affordance; an `aria-live` announcement signals the in-flight state. |
busy | open | Async filter results return successfully. The option list re-renders with the new matches; an `aria-live` announcement signals the result count. |
open | invalid | `strict: true` and the input blurs with a value that does not match any option. The combobox surfaces an inline error and sets `aria-invalid="true"` on the input. |
invalid | closed | User clears the input or selects a valid option from a subsequently-opened listbox. `aria-invalid` is removed; the inline error is cleared. |
Cross-framework expression
| Framework | Structure mechanism | Variant mechanism |
|---|---|---|
| Web Components | A `<ui-combobox>` host with named slots for `option`-bearing children and an internal portal for the listbox; floating positioning via the Popover API or floating-ui | attributes for variant (`single-select` / `multi-select` / `creatable`); CSS `[data-state="open"]` for transition styling |
| React | compound components (Radix `Combobox` does not exist as of 2026-04, but Headless UI `Combobox`, Downshift, or React Aria `useComboBox` follow the compound pattern with portal-based listbox) | props with discriminated unions for variant; controlled or uncontrolled value props; `data-state` attribute on the listbox element |
| Angular (signals) | Angular CDK Overlay + `cdk-listbox`; signal-based query, results, and selectedValue inputs / outputs | input<'single-select' | 'multi-select' | 'creatable'>(); input<'startsWith' | 'contains' | 'fuzzy'>() for filterMode |
| Vue | Headless UI `<Combobox>` / `<ComboboxInput>` / `<ComboboxButton>` / `<ComboboxOptions>` / `<ComboboxOption>` | defineProps with literal-union types; `multiple` prop for multi-select |
Events
inputChangeselectionChangeopenChange
Form integration
- name attribute
- The inner `<input>` carries the form `name` attribute; the combobox wrapper does not. Submitting a form writes the input's current value to FormData under the configured name — the typed string for free-input variants, the canonical value of the selected option for strict mode.
- FormData serialization
- Single-select strict mode submits the selected option's *canonical value* (id or token), not the *display label*. Multi-select variants submit one entry per selected value (multiple FormData entries with the same `name` key) to match `<select multiple>` native behaviour. Creatable variants submit user-created values verbatim.
- form.reset()
- `form.reset()` restores the inner input value to its `defaultValue`, closes the popup, and clears `aria-invalid` plus any `setCustomValidity()` message. Selected-value state held in framework state outside the DOM needs an explicit reset hook (an `onReset` listener on the parent form syncs the framework state).
- HTML5 validation
- The inner `<input>` participates in HTML5 validation: `required` triggers the `:invalid` pseudo-class when empty; `setCustomValidity('Pick a value from the list')` is the canonical hook for strict-mode "must select from list" enforcement on blur. The error message is announced via the inline-error pattern referenced through `aria-describedby`.
Performance thresholds
virtualisedListboxoption-count≥200itemsAbove ~200 options, render virtualisation becomes necessary — open latency, memory footprint, and screen-reader announcement count all degrade beyond this threshold on commodity hardware. Below 200, all options can stay in the DOM with no measurable cost. The previously-prose "~200 items" hint in mistake `combobox-non-virtualised-large-list` is the canonical source of this threshold.
asyncFilterDebouncekeystroke-interval≥150msAsync filter requests should debounce keystrokes by ~150ms to avoid request floods on fast typers without feeling laggy. The 150ms threshold sits below the 200ms perceptual threshold for input feedback while suppressing 80%+ of in-flight stale requests on a 60–80 wpm typer.
Accessibility
| Slot | Accessibility hint | |
|---|---|---|
input | `role="combobox"`, `aria-expanded` reflecting popup state, `aria-controls` referencing the listbox id, and `aria-activedescendant` pointing at the currently-highlighted option when navigating with arrow keys. The input retains DOM focus throughout — focus never moves into the listbox. | |
clear-button | Real button with accessible name ("Clear" or "Clear search"). Clicking returns focus to the input. Escape with the popup closed performs the same action (clear value), per APG. | |
trigger-button | `tabindex="-1"` because the input owns focus; `aria-label` ("Show options"). Clicking toggles `aria-expanded` on the combobox input, not on this button. | |
listbox | `role="listbox"` and `id` referenced by the input's `aria-controls`. Listbox is rendered in the document but positioned via portal; do not move DOM focus into it. | |
option | `role="option"` and a unique `id` per option. The currently- highlighted option is referenced by the input's `aria-activedescendant`. Selected options have `aria-selected="true"`. | |
empty-state | Announce the no-results state to assistive tech via `role="status"` (polite) on a live region inside the listbox, or via `aria-live="polite"` on the empty-state container. Avoid putting `role="option"` on the empty-state — it is not selectable. |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
Tab | Focus enters the input. Focus never moves into the listbox even when the popup is open — DOM focus stays on the input throughout. From the input, Tab leaves the combobox entirely. |
ArrowDown / ArrowUp | With popup closed, opens the popup and highlights the first / last option. With popup open, moves the highlight to the next / previous option via `aria-activedescendant`. The input retains DOM focus. |
Enter | With popup open and an option highlighted, commits the highlighted option (input value updates, popup closes for single-select). With popup closed, behaves like default form submission if applicable. |
Escape | With popup open, closes the popup without changing the value. With popup already closed, clears the input value (matches APG guidance for combobox). |
Home / End | With popup open, highlights first / last option in the listbox. With popup closed, default text-input caret behavior (line start / end). |
Screen-reader announcements
| Trigger | Expected |
|---|---|
| Input focused, popup closed | SR announces "<label>, combobox, expanded false" plus the current input value. The combobox role is on the input itself, not on a wrapping element. |
| Option highlighted via ArrowDown | SR announces "<option label>, <position> of <total>". The announcement is driven by `aria-activedescendant` updating to the highlighted option's id. |
| Async filter results return | A live region (`aria-live="polite"` inside the listbox or as a sibling) announces the result count — e.g. "12 results". Without this, SR users have no signal that filtering completed. |
| `strict: true` blur with unmatched value | Input gets `aria-invalid="true"`; the inline error referenced via `aria-describedby` is announced ("Pick a value from the list"). |
axe-core rules to assert
aria-required-attraria-valid-attr-valuearia-rolesaria-input-field-namecolor-contrastfocus-order-semantics
Common mistakes
#combobox-focus-into-listbox
Arrow Down moves DOM focus into the listbox
The implementation uses real `tabindex` on each option and moves focus there. Typing no longer routes to the input; Escape behaves unexpectedly.
Keep DOM focus on the input. Track the highlighted option with `aria-activedescendant` referencing the option's `id`. Arrow keys mutate the highlighted index; Enter selects.
#combobox-no-aria-expanded
`aria-expanded` not toggled on open / close
The input misses `aria-expanded`, so screen readers cannot tell whether the popup is open. Users hear "combobox" with no state cue.
Set `aria-expanded="true"` when the listbox is open, `false` when closed. Keep this in sync with the `data-state` / transition state of the popup.
#combobox-strict-without-feedback
Strict mode silently rejects invalid input on blur
The combobox is strict (must select from the list) but on blur with an unmatched value the input simply reverts. The user sees their typing disappear with no indication why.
On blur with an unmatched value, either snap to the best match (with an `aria-live` announcement) or surface an `invalid` state with an inline error referenced by `aria-describedby`. Never silently revert.
#combobox-non-virtualised-large-list
A list of 5,000 options renders all DOM nodes
Every option is in the DOM whether visible or not. Open latency, memory, and screen-reader announcement all suffer.
Virtualise the option list when it exceeds ~200 items. Keep the visible window plus a small overscan in the DOM; emit a live-region count ("100 results") so users know the size without scrolling.
#combobox-clear-button-tab-stop
Clear button appears in the keyboard tab order
Tabbing from the input lands on the × button instead of moving past the combobox. The natural keyboard flow expects the clear affordance to be operable via the input itself.
Give the clear button `tabindex="-1"` and bind Escape on the input (when the popup is closed) to clear the value. The mouse user can still click the button; the keyboard user uses Escape.
Figma↔Code mismatches
- 01 Figma
A combobox drawn as a text input with a dropdown panel rendered beneath, both inside the same Figma frame
CodeThe listbox is rendered in a portal at the end of the document body, positioned with floating-ui or popper, and z-indexed above other content
ConsequenceDesigners approximate the listbox at "natural" stacking order, then are surprised when the implementation appears to "escape" its container (e.g., over a parent modal or sticky header).
CorrectDocument the portal model in the canonical reference and the Figma file. Designers keep the dropdown rendered for review purposes but treat it as a floating layer; developers always portal in production.
- 02 Figma
Each typeahead state (default / focused / open / loading / invalid / disabled) modeled as a Figma variant
CodeData states (`open`, `busy`, `invalid`) toggled by the application; interactive states (`focus-visible`, `disabled`) are CSS pseudo-classes
ConsequenceVariant explosion (6 states × 3 sizes × 2 widths = 36+ variants) and ambiguity about which states are mutually exclusive.
CorrectReserve Figma variants for the structural variants (single / multi / creatable). States are documented once in the canonical reference, with a clear matrix of which are mutually exclusive.
- 03 Figma
Multi-select rendered as chips inside the input field, drawn statically
CodeChips are rendered dynamically; their addition / removal triggers re-layout that shifts the typing caret position
ConsequenceThe dynamic re-layout is invisible to the static Figma file; developers ship comboboxes whose typing behaviour breaks when chips wrap to a new row.
CorrectDocument the multi-select chip behavior explicitly: chips wrap naturally, the input width is the remaining row space (with a minimum), and adding / removing a chip preserves the caret position. Annotate the Figma frame with a note about the dynamic re-layout.
- 04 Figma
A "loading" spinner drawn inside the listbox as a permanent visual element
CodeThe busy state replaces (not supplements) the option list while async results are in-flight, and is announced via a live region
ConsequenceThe spinner appears on every render in mocks, encouraging an implementation that always shows a spinner; assistive-tech users get no announcement when results arrive.
CorrectTreat busy as a data state replacing the option list. Document that an `aria-live` announcement fires when results change. The Figma file uses a state-toggle to show busy vs. loaded explicitly.