Bridge 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.

Highlight
Fig 1.1 · Combobox · Bridge view
Both

Figma↔Code mismatches

Where designer and developer worlds typically misalign on this component.

  1. 01
    Figma

    A combobox drawn as a text input with a dropdown panel rendered beneath, both inside the same Figma frame

    Code

    The 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

    Consequence

    Designers 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).

    Correct

    Document 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.

  2. 02
    Figma

    Each typeahead state (default / focused / open / loading / invalid / disabled) modeled as a Figma variant

    Code

    Data states (`open`, `busy`, `invalid`) toggled by the application; interactive states (`focus-visible`, `disabled`) are CSS pseudo-classes

    Consequence

    Variant explosion (6 states × 3 sizes × 2 widths = 36+ variants) and ambiguity about which states are mutually exclusive.

    Correct

    Reserve 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.

  3. 03
    Figma

    Multi-select rendered as chips inside the input field, drawn statically

    Code

    Chips are rendered dynamically; their addition / removal triggers re-layout that shifts the typing caret position

    Consequence

    The dynamic re-layout is invisible to the static Figma file; developers ship comboboxes whose typing behaviour breaks when chips wrap to a new row.

    Correct

    Document 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.

  4. 04
    Figma

    A "loading" spinner drawn inside the listbox as a permanent visual element

    Code

    The busy state replaces (not supplements) the option list while async results are in-flight, and is announced via a live region

    Consequence

    The spinner appears on every render in mocks, encouraging an implementation that always shows a spinner; assistive-tech users get no announcement when results arrive.

    Correct

    Treat 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.

Both

Variants, properties, states

Variants

Structurally different versions of the component.

single-selectmulti-selectcreatable

Properties

The same component, parameterised.

PropertyType
filterModestartsWith | contains | fuzzy | none
strictboolean
virtualisedboolean
asyncboolean

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactivedisabled
data
closedopenbusyinvalid
Both

Figma ↔ Code property map

FigmaTypeCodeNotes
VariantVariantvariantMaps single-select / multi-select / creatable.
Filter ModeVariantfilterModestartsWith / contains / fuzzy / none. Figma encodes for documentation; code drives the filter function selection.
StateVariantdata-stateFigma exposes closed / open / busy / invalid as a Variant for preview. In code these are data states toggled by application logic, mirrored on the host as a `data-state` attribute.
Has Clear ButtonBooleanclearSlot-visibility toggle; code conditionally renders the clear-button slot when the input has a value.
Has Trigger ButtonBooleantriggerOptional disclosure affordance; code conditionally renders the chevron trigger.
StrictBooleanstrict
AsyncBooleanasyncIn Figma toggles the busy-state preview. In code marks the filter as async (results arrive via a Promise / observable).
VirtualisedBooleanvirtualisedDocumentation-only in Figma; in code drives the listbox-rendering strategy above ~200 options.
PlaceholderTextplaceholder
Both

State transitions

FromToTrigger
closedopenUser 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.
openclosedUser 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.
openbusyTyped 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.
busyopenAsync filter results return successfully. The option list re-renders with the new matches; an `aria-live` announcement signals the result count.
openinvalid`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.
invalidclosedUser clears the input or selects a valid option from a subsequently-opened listbox. `aria-invalid` is removed; the inline error is cleared.
Designer

Figma anatomy

Slot Figma type Hint
input instance Text input component instance; min-width drives the field size
clear-button instance Icon button instance; visibility bound to "has value" property
trigger-button instance Icon button instance; chevron rotates with `aria-expanded`
listbox frame Floating frame (overlay layer); width matches input by default
option instance Option component instance with selected and highlighted variants
empty-state frame Centered text with optional icon; visibility bound to "no matches" state
Dev

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
Dev

Cross-framework expression

FrameworkStructure mechanismVariant 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
Both

Events

  1. inputChange
    Payload
    The current input string. Fires on every keystroke and on programmatic input mutation. May be empty after the user clears. During async filtering it remains the *typed* string, not the canonical selection label.
    Web Components
    Native `input` event on the inner `<input>` slot, re-emitted on the `<ui-combobox>` host as `inputChange` with `event.detail = { value }`.
    React
    `onInputValueChange(value: string)` (Headless UI `Combobox`) or `onInputChange(value)` (React Aria `useComboBox`, controlled).
    Angular Signals
    `output<string>('inputChange')`; pair with `[(inputValue)]` for two-way binding on the typed string.
    Vue
    `@update:inputValue` for `v-model:input-value` binding on the typed string, separate from the value `v-model`.
  2. selectionChange
    Payload
    The selected value (single-select), the array of selected values (multi-select), or `null` / `[]` after clear. Always reflects the canonical value, not the input string. Does not fire while the user is typing — only when they commit a selection or clear.
    Web Components
    `change` CustomEvent on the host with `event.detail = { value }`. Multi-select hosts may emit `event.detail = { value: T[] }` — document the shape per host.
    React
    `onSelectionChange(key | Set<Key>)` (React Aria `useComboBox`) or `onChange(value)` (Headless UI). React Aria's set form is used for the multi-select variant.
    Angular Signals
    `output<T | T[] | null>('selectionChange')`; the union covers single, multi, and cleared paths.
    Vue
    `@update:modelValue` for `v-model`; multi-select uses an array `modelValue`.
  3. openChange
    Payload
    Boolean. `true` when the listbox opens, `false` when it closes. Mirrors `aria-expanded` on the input. Fires on user-driven open (typing, arrow keys, trigger click) and on close paths (Escape, outside click, selection in single-select).
    Web Components
    `openChange` CustomEvent with `event.detail = { open }`. Distinct from selection events so consumers that only care about popup lifecycle do not subscribe to selection.
    React
    `onOpenChange(open: boolean)` (Headless UI, React Aria); fires on the same edge as `aria-expanded` flips.
    Angular Signals
    `output<boolean>('openChange')`; common pattern is `[(open)]` for controlled-from-parent scenarios.
    Vue
    `@update:open` for `v-model:open`; suppressed when the open state is computed and not externally controlled.
Both

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`.
Both

Performance thresholds

  • virtualisedListboxoption-count200items

    Above ~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-interval150ms

    Async 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.

Both

Internationalisation

RTL · mirroring

Clear-button and trigger-button move from inline-end of the input to inline-start (visual right→left flip) via logical positioning. Chevron rotation is *not* mirrored — the rotation indicates open/closed state, not direction. Listbox aligns to the input's inline-start edge (same logical anchor in both directions). Input caret follows document direction; mixed-direction input (typing Hebrew into a Latin-default field) honours the input's own `dir` attribute.

Text expansion

Option labels can grow significantly; listbox width matches the input by default and may need a max-width override when option labels exceed the input width. Truncation with ellipsis is the canonical fallback for option labels exceeding the listbox width; aria-label on the option provides the full text for SR users. Placeholder text is the riskiest expansion target — many locales' "Search…" translations exceed 30% length.

Both

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.
Both

Accessibility acceptance

Keyboard walk

KeysExpected
TabFocus 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 / ArrowUpWith 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.
EnterWith 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.
EscapeWith popup open, closes the popup without changing the value. With popup already closed, clears the input value (matches APG guidance for combobox).
Home / EndWith popup open, highlights first / last option in the listbox. With popup closed, default text-input caret behavior (line start / end).

Screen-reader announcements

TriggerExpected
Input focused, popup closedSR 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 ArrowDownSR announces "<option label>, <position> of <total>". The announcement is driven by `aria-activedescendant` updating to the highlighted option's id.
Async filter results returnA 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 valueInput 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-attr
  • aria-valid-attr-value
  • aria-roles
  • aria-input-field-name
  • color-contrast
  • focus-order-semantics
Both

Common mistakes

#combobox-focus-into-listbox

Arrow Down moves DOM focus into the listbox

Problem

The implementation uses real `tabindex` on each option and moves focus there. Typing no longer routes to the input; Escape behaves unexpectedly.

Fix

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

Problem

The input misses `aria-expanded`, so screen readers cannot tell whether the popup is open. Users hear "combobox" with no state cue.

Fix

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

Problem

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.

Fix

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

Problem

Every option is in the DOM whether visible or not. Open latency, memory, and screen-reader announcement all suffer.

Fix

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

Problem

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.

Fix

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.