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

Also called Autocomplete Typeahead

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.

  • text-input

    `TextInput` accepts free-form text with no constrained set of values; `Combobox` filters and commits a value from a canonical list (or, in `creatable` variants, opts in to free text explicitly). Migrate TextInput to Combobox when a finite set of acceptable values exists and typeahead narrows the field meaningfully.

  • checkbox

    `Checkbox` is a binary form field that toggles a single boolean per option (multi-select via a list of Checkbox); `Combobox` filters from a list with typeahead and commits one or more selected options to a single field. Bounded short option-sets (under ~10 items) read better as a list of Checkbox; large unbounded sets need Combobox with multi-select for the filter-and-overflow handling.

  • textarea

    `Textarea` accepts arbitrary multi-line free-form prose; `Combobox` commits one structured value from a constrained list with optional typeahead filter. Combobox uses a listbox popup; Textarea is a flat field. Use Combobox for structured single-value choice from a bounded set; Textarea for unstructured prose entry.

  • grid-pattern

    `Combobox` is a single-input affordance with a listbox of selectable options (one-dimensional linear list); `Grid` is a 2D structure with cells in rows and columns (two-dimensional spatial navigation with roving-tabindex + arrow-key cell focus). Combobox suits "pick one from a list"; Grid suits "navigate cell-by-cell through structured data". Their keyboard models diverge — Combobox uses ArrowDown / ArrowUp for list traversal, Grid uses all four arrows for cell navigation.

Combobox composes a text input with a popup listbox of suggestions — the canonical pattern for filtered selection from a long list. It implements the APG combobox role with autocomplete-list semantics: the input drives the list, the list reflects matches, and aria-activedescendant follows keyboard navigation while DOM focus stays on the input. The reference covers the six-edge state graph between closed, open, busy, and invalid; the four event payloads; the keyboard contract that distinguishes Tab from Enter from Escape; and the mobile-platform divergence where a native picker substitutes for the popup.

Highlight
Fig 1.1 · Combobox · Dev view

Implementations

How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.

cdk MatAutocomplete / MatAutocompleteTrigger (from @angular/material/autocomplete)
app.component.ts
import { Component, signal, computed } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
@Component({
selector: 'app-demo',
standalone: true,
imports: [ReactiveFormsModule, MatAutocompleteModule, MatInputModule, MatFormFieldModule],
template: `
<mat-form-field>
<mat-label>Country</mat-label>
<input
matInput
[matAutocomplete]="auto"
[formControl]="searchCtrl"
placeholder="Start typing…"
/>
<mat-autocomplete
#auto="matAutocomplete"
[displayWith]="displayFn"
[requireSelection]="true"
[autoActiveFirstOption]="true"
(optionSelected)="onSelected($event)"
(opened)="onOpened()"
(closed)="onClosed()"
>
@for (option of filteredOptions(); track option.id) {
<mat-option [value]="option">{{ option.label }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
`,
})
export class AppDemoComponent {
private readonly allOptions = [
{ id: 'de', label: 'Germany' },
{ id: 'fr', label: 'France' },
{ id: 'us', label: 'United States' },
];
searchCtrl = new FormControl<{ id: string; label: string } | string>('');
filteredOptions = computed(() => {
const raw = this.searchCtrl.value;
const q = typeof raw === 'string' ? raw.toLowerCase() : (raw?.label ?? '').toLowerCase();
return this.allOptions.filter(o => o.label.toLowerCase().includes(q));
});
displayFn(option: { id: string; label: string } | string | null): string {
return option && typeof option !== 'string' ? option.label : (option as string) ?? '';
}
onSelected(event: MatAutocompleteSelectedEvent): void {
// event.option.value is the full object bound via [value]
}
onOpened(): void {}
onClosed(): void {}
}

Divergence

From Type → To Rationale
anatomy[input] reshaped Two-directive split: a plain `<input matInput [matAutocomplete]="ref">` is the text field; `MatAutocompleteTrigger` is the directive applied to that input that wires ARIA (`role="combobox"`, `aria-expanded`, `aria-autocomplete="list"`, `aria-controls`, `aria-activedescendant`, `aria-haspopup="listbox"`) and manages the overlay lifecycle. There is no single "combobox input" element — the native input and the trigger directive are separate tokens. Source: github.com/angular/components blob src/material/autocomplete/autocomplete-trigger.ts (fetched 2026-05-04). Canonical `input` is a single slot carrying both the editable surface and the combobox ARIA role. Angular Material separates the DOM element (native `<input>`) from the behaviour layer (MatAutocompleteTrigger directive) so that any existing input — including one inside a MatFormField — can be progressively enhanced into a combobox without replacing it. This makes integration with mat-form-field validation and label animation seamless but means the ARIA contract is spread across two tokens rather than one.
anatomy[listbox] renamed `<mat-autocomplete>` element with `#ref="matAutocomplete"` (template reference); renders a panel with `role="listbox"` via an Angular CDK Overlay. The panel host element is portalled to the overlay container (`<div class="cdk-overlay-container">`) at the root of the document. Panel ID is wired into the trigger's `aria-controls`. Source: github.com/angular/components blob src/material/autocomplete/autocomplete.ts and autocomplete.html (fetched 2026-05-04). Material calls the popup element "autocomplete" — its library name for the canonical combobox pattern. The panel carries `role="listbox"`, matching the canonical semantic, but consumers author it as `<mat-autocomplete>` rather than a generic listbox container. The overlay portal model matches the canonical description of the listbox as floating above adjacent content positioned relative to the input.
anatomy[option] extended + `<mat-option>` supports a `multiple` mode (driven by the parent panel context) that renders a `MatPseudoCheckbox` inside each option for multi-select visual feedback. The shared `MatOption` class is also used by `MatSelect`, `MatChipListbox`, and CDK Listbox — it is not autocomplete-specific. Source: github.com/angular/components blob src/material/core/option/option.ts (fetched 2026-05-04). Canonical `option` anatomy describes the single-select case (aria-selected, highlighted state). Material extends it with a pseudo-checkbox for multi-select without a separate `multi-option` slot — the same element adapts based on the panel's multiple state. The shared-class design also means MatOption carries `onSelectionChange` Output (not just role="option"), enabling the trigger to subscribe to selection events reactively.
anatomy[clear-button] omitted MatAutocomplete does not ship a built-in clear affordance. Consumers must author their own clear button (typically a `<button matSuffix>` inside MatFormField) and imperatively reset the FormControl. The canonical clear-button slot — a trailing "×" that appears conditionally — has no Material equivalent in the autocomplete component. Source: github.com/angular/components blob src/material/autocomplete/autocomplete.ts (fetched 2026-05-04); no `clearable` or `showClearButton` input exists.
anatomy[trigger-button] omitted There is no built-in disclosure chevron or toggle button. The canonical trigger-button (chevron at the trailing edge opening the listbox without typing) is absent. MatAutocomplete opens exclusively via the input's focus or keystroke events wired by MatAutocompleteTrigger — there is no click-to-toggle-open button. Consumers wanting a toggle button must author one themselves and call `trigger.openPanel()` / `trigger.closePanel()` imperatively. Source: github.com/angular/components blob src/material/autocomplete/autocomplete-trigger.ts (fetched 2026-05-04).
anatomy[empty-state] omitted The `<mat-autocomplete>` panel has no empty-state slot or no-results projection point. When the consumer's filtered option list is empty, the panel opens with zero items and no affordance. Consumers must handle the empty case themselves — either conditionally closing the panel or projecting a non-option element into `<mat-autocomplete>`. The template (autocomplete.html, 18 lines, plain `<ng-content>`) has no reserved slot for empty-state content. Source: github.com/angular/components blob src/material/autocomplete/autocomplete.html (fetched 2026-05-04).
axes.properties[filterMode] omitted Canonical `filterMode` (startsWith / contains / fuzzy / none) is a component-level property. MatAutocomplete performs no filtering itself — the consumer is responsible for producing the option list passed to the panel (via `@for`, `*ngFor`, or an async pipe). There is no `filterMode` input and no built-in filter function. The `displayWith` input (type: `(value: T) => string | null`) maps a selection object to a display string for the input field after selection, but does not drive filtering. Source: github.com/angular/components blob src/material/autocomplete/autocomplete.ts (fetched 2026-05-04).
axes.variants omitted Canonical variants are single-select / multi-select / creatable. MatAutocomplete has no `variant` input. Multi-select is achievable by passing a FormControl<T[]> and keeping the panel open after selection (consumer-managed), but there is no first-class `multiple` input on `<mat-autocomplete>` itself. Creatable (free-text commit) is approximated by omitting `requireSelection` (default), but no explicit `creatable` mode exists. Source: github.com/angular/components blob src/material/autocomplete/autocomplete.ts (fetched 2026-05-04); no `variant` or `multiple` input present.
axes.properties[strict] renamed `requireSelection: boolean` on `<mat-autocomplete>` (default: from `MAT_AUTOCOMPLETE_DEFAULT_OPTIONS`, typically false). When true, if the user blurs without selecting an option the input value is restored to the last valid selection (or cleared if none). Source: github.com/angular/components blob src/material/autocomplete/autocomplete.ts (fetched 2026-05-04). Canonical `strict: boolean` enforces that the committed value must come from the option list. Material's `requireSelection` is the direct equivalent — same semantics, different name. The revert-on-blur behaviour matches the canonical invalid-to-closed transition (value is cleared or snapped). The name change reflects Material's preference for positive-polarity "require" over the canonical negative-polarity "strict" (which implies rejection).
events[selectionChange] renamed `optionSelected: EventEmitter<MatAutocompleteSelectedEvent>` on `<mat-autocomplete>`. Payload shape: `{ source: MatAutocomplete, option: MatOption }` — carries the full option reference rather than a plain value. Source: github.com/angular/components blob src/material/autocomplete/autocomplete.ts (fetched 2026-05-04). Canonical `selectionChange` emits the selected value (T | T[] | null). Material's `optionSelected` emits a `MatAutocompleteSelectedEvent` object carrying both the panel reference and the `MatOption` instance. Consumers access the value via `event.option.value`, not directly as the payload. This richer payload enables consumers to inspect disabled state, selection state, and label text on the option object, but diverges from the flat canonical payload shape.
events[openChange] reshaped Two separate outputs on `<mat-autocomplete>`: `opened: EventEmitter<void>` fires when the panel opens; `closed: EventEmitter<void>` fires when the panel closes. Neither carries a boolean payload. Source: github.com/angular/components blob src/material/autocomplete/autocomplete.ts (fetched 2026-05-04). Canonical `openChange` is a single boolean output (true = open, false = closed) matching the `aria-expanded` flip. Material splits the two edges into `opened` (void) and `closed` (void), requiring consumers who need a unified boolean state to maintain it themselves. The split also matches Material's event-naming convention for open/close lifecycle hooks (the same pattern appears in MatDrawer's openedChange design).
events[inputChange] omitted MatAutocompleteTrigger does not emit an `inputChange` or `inputValueChange` output. Consumers subscribe to input changes via the native input's `(input)` event or via `FormControl.valueChanges` observable. There is no library-level canonical event surface for the typed string. Source: github.com/angular/components blob src/material/autocomplete/autocomplete-trigger.ts (fetched 2026-05-04); no output matching `inputChange` exists.
Why this audit reads the way it does

Angular Material calls this pattern "Autocomplete" — its library name for the canonical Combobox (documented as an alternateName in the canonical). The four most significant divergences: 1. Two-directive architecture — the text input and the ARIA/overlay wiring are separate tokens (native <input> + MatAutocompleteTrigger directive + <mat-autocomplete> panel). The canonical models a single input slot carrying both the editable surface and the combobox role. 2. No built-in filtering — MatAutocomplete delegates all filter logic to the consumer. The canonical filterMode property (startsWith / contains / fuzzy) has no Material equivalent; the consumer owns the filtered option array. 3. No clear-button or trigger-button — both optional canonical anatomy slots are absent. Material's philosophy is full content-projection — consumers author these affordances as MatFormField suffixes or standalone buttons. 4. No empty-state slot — the panel has no built-in no-results affordance; the consumer must handle the zero-option case in their template. Material strengths relative to canonical: 1. requireSelection (strict mode) reverts cleanly on blur with no consumer code. 2. displayWith maps object values to display strings automatically — essential for object-valued FormControls where the input must show a label. 3. autoSelectActiveOption completes selection on blur without Enter, reducing required keystrokes for power users. 4. MAT_AUTOCOMPLETE_DEFAULT_OPTIONS DI token enables app-wide defaults for requireSelection, autoActiveFirstOption, and panelWidth without per-instance wiring.

headlessui Combobox / ComboboxInput / ComboboxButton / ComboboxOptions / ComboboxOption
import { useState } from 'react';
import {
Combobox, ComboboxInput, ComboboxButton,
ComboboxOptions, ComboboxOption,
} from '@headlessui/react';
const people = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Carol' },
];
export function PeopleCombobox() {
const [selected, setSelected] = useState(null);
const [query, setQuery] = useState('');
const filtered = query === ''
? people
: people.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase())
);
return (
<Combobox value={selected} onChange={setSelected} onClose={() => setQuery('')}>
<div className="relative">
<ComboboxInput
displayValue={(person) => person?.name ?? ''}
onChange={(e) => setQuery(e.target.value)}
className="w-full border rounded px-3 py-2 pr-8 data-focus:ring-2"
/>
<ComboboxButton className="absolute inset-y-0 right-0 px-2">
<span aria-hidden="true">&#8964;</span>
</ComboboxButton>
</div>
<ComboboxOptions
anchor="bottom"
className="w-[var(--input-width)] rounded shadow-lg bg-white empty:hidden"
>
{filtered.map((person) => (
<ComboboxOption
key={person.id}
value={person}
className="px-3 py-2 cursor-default data-focus:bg-blue-100 data-selected:font-semibold"
>
{person.name}
</ComboboxOption>
))}
</ComboboxOptions>
</Combobox>
);
}

Divergence

From Type → To Rationale
anatomy[clear-button] omitted Headless UI ships no clear-button primitive. The canonical `clear` slot is an × affordance that appears when the input has a value and clears it on click. Headless UI exposes no such component; consumers must author a plain `<button>` inside the `Combobox` render scope, wire `onClick` to reset the controlled value, and manage its visibility themselves. The `onClose` callback on `Combobox` is the nearest affordance but it fires on dropdown close, not on value clear. Source: https://headlessui.com/react/combobox (v2.1, 2026-05-05).
anatomy[listbox] renamed ComboboxOptions Canonical `listbox` slot maps to `role="listbox"`. Headless UI's `ComboboxOptions` renders a `<div>` by default and injects the correct ARIA roles internally — but the component name is `ComboboxOptions`, not `ComboboxListbox`. Consumers who inspect the rendered HTML will find `role="listbox"` present; the divergence is purely at the API naming layer. Source: https://headlessui.com/react/combobox (v2.1, 2026-05-05).
anatomy[option] renamed ComboboxOption Canonical `option` slot expects `role="option"`. `ComboboxOption` renders a `<div>` by default and applies ARIA option role internally. The rename is API-level; the ARIA contract is honoured. Source: https://headlessui.com/react/combobox (v2.1, 2026-05-05).
anatomy[empty-state] omitted No built-in empty-state slot. The canonical `empty` slot renders an accessible `role="status"` message when no options match. Headless UI provides no such component; the `empty:hidden` CSS utility on `ComboboxOptions` hides the dropdown when there are no `ComboboxOption` children, but there is no fallback content API and no live-region announcement. Consumers must author their own conditional render inside `ComboboxOptions` and manage the `aria-live` announcement themselves. Source: https://headlessui.com/react/combobox (v2.1, 2026-05-05).
events[inputChange] reshaped onChange on ComboboxInput delivers a native React ChangeEvent<HTMLInputElement>, not a plain string Canonical `inputChange` payload is a plain string (the typed value). Headless UI's `ComboboxInput.onChange` receives the raw DOM `ChangeEvent<HTMLInputElement>` — consumers must call `event.target.value` to extract the string. This matches standard React `<input onChange>` convention but diverges from the canonical abstracted payload. Source: https://headlessui.com/react/combobox (ComboboxInput props, 2026-05-05).
events[selectionChange] renamed onChange on Combobox root Canonical `selectionChange` lives on the combobox host element. Headless UI places `onChange` on the root `Combobox` component (not on `ComboboxInput`). In multi-select mode `onChange` receives `T[]`; in single-select it receives `T | null`. The payload shape matches canonical intent but the event name and attachment point differ. Source: https://headlessui.com/react/combobox (Combobox props — onChange, 2026-05-05).
events[openChange] reshaped onClose callback on Combobox root (no open-path callback; open edge derived from render prop) Canonical `openChange` fires on both edges — open (`true`) and close (`false`). Headless UI only surfaces `onClose`, which fires when the dropdown closes. There is no `onOpen` callback; consumers who need to detect the open edge must read the `open` render prop from the `Combobox` children or use a `useEffect` on controlled state. Source: https://headlessui.com/react/combobox (Combobox props — onClose, 2026-05-05).
axes.variants[creatable] omitted Headless UI Combobox has no `creatable` variant. The canonical `creatable` variant allows users to type a value not present in the option list and commit it as a new entry. Headless UI's `by` prop enables object comparison, and `nullable` allows clearing, but there is no API to accept a typed string as a new value without it appearing in `ComboboxOptions`. Consumers must author the "create option" row manually inside `ComboboxOptions` and handle the new-value path in `onChange`. Source: https://headlessui.com/react/combobox (Combobox props, 2026-05-05).
axes.properties[virtualised] reshaped virtual prop object on Combobox root — shape: { options: T[], disabled?: (item: T) => boolean } Canonical `virtualised` is a boolean flag; virtualisation strategy is implementation-defined. Headless UI ships a first-class virtual scrolling API via a `virtual` prop object on the root `Combobox` component. When `virtual` is set, `ComboboxOptions` renders via a render-function child `{ option }` rather than mapping `ComboboxOption` children directly. This is more structured than the canonical boolean but requires a different child API, breaking the non-virtual composition pattern. Source: https://headlessui.com/react/combobox (Combobox — virtual prop, 2026-05-05).
anatomy[input] extended + `displayValue: (item: T) => string` prop on `ComboboxInput`. When a structured object is selected, `displayValue` converts it to the human-readable label shown in the input. The canonical anatomy has no equivalent — it assumes the committed value is already a string. This prop is required when `value` is an object; omitting it causes the input to display `[object Object]`. The canonical anatomy deals exclusively with string values. Headless UI's generic type parameter `T` allows any object as the combobox value, so a projection function from `T` to string is necessary to populate the input's text content. Source: https://headlessui.com/react/combobox (ComboboxInput props — displayValue, 2026-05-05).
anatomy[listbox] extended + `anchor` prop on `ComboboxOptions` drives built-in floating positioning. String values: `"top"`, `"right"`, `"bottom"`, `"left"` with optional `"start"` / `"end"` modifiers. Object form accepts `{ to, gap, offset, padding }`. When `anchor` is set, `portal` is auto-enabled and the options panel is rendered in a portal. CSS custom properties `--input-width`, `--button-width`, `--anchor-gap`, `--anchor-offset`, and `--anchor-padding` are injected for consumer sizing. `modal` prop (default `true`) enables scroll locking, focus trapping, and inerting of other page elements when the dropdown is open. The canonical anatomy documents floating/portal behaviour as a Figma-code mismatch concern but prescribes no specific library prop. Headless UI ships first-party positioning (backed by Floating UI) to remove the need for consumers to wire a third-party positioning library, and adds `modal` accessibility semantics on the options panel as an opt-out (default on) rather than opt-in. Source: https://headlessui.com/react/combobox (ComboboxOptions props — anchor, portal, modal, 2026-05-05).
axes.properties[filterMode] extended + `immediate: boolean` prop on `Combobox` root. When `true`, the options dropdown opens as soon as `ComboboxInput` receives focus, before any characters are typed. The canonical `filterMode` axis has no equivalent open-on-focus trigger — canonical open triggers are typing a printable character, pressing Down/Up arrow, or clicking the trigger button. `immediate` addresses the "show all options on focus, then filter" UX pattern common in command palettes. Canonical open triggers (typing, arrow keys, trigger button) do not cover this focus-driven open path, making `immediate` a meaningful extension beyond the canonical property axis. Source: https://headlessui.com/react/combobox (Combobox props — immediate, 2026-05-05).
axes.states[invalid] extended + `invalid: boolean` prop on `Combobox` root. Sets `data-invalid` on all child components and exposes `invalid` as a render prop. The canonical `invalid` data state is driven purely by application logic (e.g. strict-mode blur with unmatched value); Headless UI promotes it to a first-party boolean prop that propagates the invalid marker through the component tree without requiring consumers to manually set `aria-invalid` on the inner input. Surfacing `invalid` as a root prop reduces the boilerplate for form-validation integration: consumers pass `invalid={!!formError}` and `data-invalid` propagates to every child automatically, including `ComboboxInput` which will receive `aria-invalid="true"`. The canonical model relies on the consumer setting `aria-invalid` on the input directly. Source: https://headlessui.com/react/combobox (Combobox props — invalid, 2026-05-05).
formIntegration.name extended + `name: string` and `form: string` props on `Combobox` root. When `name` is provided, Headless UI injects a hidden native `<input>` carrying the selected value for native form submission. The `form` prop associates the hidden input with a specific form element by id. The canonical `formIntegration` says the inner `<input>` carries the form `name`; Headless UI also supports value submission via the hidden-input mechanism for the selected option value (separate from the typed string in `ComboboxInput`). The canonical model routes form submission through the visible text input — consumers set `name` on the inner `<input>` and submit the typed string. Headless UI's `name` prop on the root instead injects a hidden input carrying the committed selection value (the option object or its serialised form), cleanly separating "what the user typed" from "what was selected and submitted". This matches how Headless UI handles form submission in its `Select` and `Switch` components. Source: https://headlessui.com/react/combobox (Combobox props — name, form, 2026-05-05).
anatomy[option] extended + `order: number` prop on `ComboboxOption`. Provides a performance hint for the library's internal option ordering when options are not rendered in DOM order. Not relevant when using the `virtual` prop. The canonical `option` slot has no ordering prop. Dynamic option lists (e.g. filtered results inserted via portals or out-of-order rendering) can confuse the library's internal index tracking; `order` lets consumers declare the logical position explicitly without forcing a specific DOM order. This is a library internal optimisation with no canonical equivalent. Source: https://headlessui.com/react/combobox (ComboboxOption props — order, 2026-05-05).
Why this audit reads the way it does

Headless UI React v2.1 Combobox is an unstyled, accessibility-focused primitive that honours the APG combobox role contract but diverges from the canonical anatomy in predictable ways consistent with the library's design philosophy. The divergences cluster into five groups: 1. Missing consumer-visible slots — no clear-button primitive, no empty-state slot with live-region support. Consumers must author both from scratch, including the aria-live announcement for no-results. 2. Event model reshaping — `onChange` on ComboboxInput delivers a raw DOM ChangeEvent rather than a plain string; `onClose` exists but there is no `onOpen` equivalent. Consumers who need the open edge must derive it from render-prop `open` state. 3. Extended API beyond the canonical — `displayValue` on ComboboxInput handles object-to-string conversion; `virtual` prop ships a first-class virtualisation API; `immediate` adds open-on-focus behaviour; `anchor` on ComboboxOptions provides built-in floating positioning with CSS custom-property hooks; `modal` on ComboboxOptions enables scroll locking and inerting (default on); `invalid` on the root propagates `data-invalid` through the tree automatically; `name`/`form` on the root inject a hidden input for form submission of the selected value. 4. Variant gaps — no `creatable` variant API; `virtualised` is not a boolean flag but a structured object config that changes the child composition pattern entirely. 5. Performance hints — `ComboboxOption.order` provides an index hint for non-DOM-ordered option lists; no canonical equivalent. The ARIA contract (role="combobox" on input, aria-activedescendant for highlighted option, focus staying on the input throughout) is correctly implemented by the library and matches canonical requirements.

radix Combobox
// Radix ships no Combobox primitive. The idiomatic ecosystem pattern
// composes Popover + cmdk (command-menu library that uses Radix Dialog
// internally for its dialog variant).
import * as Popover from '@radix-ui/react-popover';
import { Command } from 'cmdk';
function Combobox({ options, value, onChange }) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<input
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content asChild align="start" sideOffset={4}>
<Command>
<Command.List role="listbox">
<Command.Empty>No results.</Command.Empty>
{options.map((opt) => (
<Command.Item
key={opt.value}
value={opt.label}
onSelect={() => { onChange(opt.value); setOpen(false); }}
>
{opt.label}
</Command.Item>
))}
</Command.List>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

Divergence

From Type → To Rationale
anatomy[input] omitted Radix ships no Combobox primitive; there is no managed input sub-component. The Popover-composition pattern requires consumers to render a bare `<input>` (or a styled wrapper) and manually wire `role="combobox"`, `aria-expanded`, `aria-haspopup`, and `aria-controls` themselves. None of these ARIA attributes are applied automatically by any Radix primitive. Source: https://www.radix-ui.com/primitives/docs/components/popover (verified 2026-05-04; no combobox docs exist — https://www.radix-ui.com/primitives/docs/components/combobox returns 404).
anatomy[listbox] reshaped Popover.Content (via Popover.Portal) + Command.List from cmdk. The floating container is Popover.Content; the item-list semantic is delegated to Command.List, which does not set role="listbox" by default — it renders a plain <div> whose role is left to the consumer. The canonical listbox slot is a single semantic unit (`role="listbox"`) floating above the input. The Radix/cmdk composition splits this into two layers: Popover.Content handles the portal and positioning (using floating-ui internally), while cmdk's Command.List handles item rendering and filtering. The `role="listbox"` must be applied by the consumer on Command.List; cmdk does not set it automatically. The `aria-controls` link from the input to the listbox id must also be wired by hand. Source: https://www.radix-ui.com/primitives/docs/components/popover#content (verified 2026-05-04); https://github.com/pacocoursey/cmdk (verified 2026-05-04).
anatomy[option] renamed Command.Item (cmdk) — renders with data-selected attribute, not role="option" cmdk's `Command.Item` is the closest analog to the canonical option slot. However, cmdk does not set `role="option"` on items — it manages its own internal selection model via data attributes (`[data-selected]`, `[data-disabled]`). For a standards-compliant combobox the consumer must add `role="option"` and unique `id` props to each Command.Item and set `aria-activedescendant` on the input manually. cmdk's built-in keyboard navigation moves an internal highlight that does not update `aria-activedescendant` on the external input. Source: https://github.com/pacocoursey/cmdk (verified 2026-05-04).
anatomy[clear-button] omitted Neither Radix Popover nor cmdk ship a managed clear-button sub-component. The canonical clear slot (an × affordance tied to input value state) is entirely a consumer responsibility in the composition pattern. Consumers must render, position, show/hide, and keyboard-wire the clear action themselves (typically binding Escape-when-closed to clear value). Source: https://www.radix-ui.com/primitives/docs/components/popover (verified 2026-05-04); no clear-button primitive exists in Radix.
anatomy[trigger-button] omitted Radix Popover.Trigger exists as a toggle button but it is not scoped to the trailing-chevron disclosure affordance described in the canonical trigger-button slot. Using Popover.Trigger as the combobox's text input (via asChild) is the typical pattern — but then the canonical trigger-button (a separate chevron icon button with `tabindex="-1"`) is not represented; the consumer must add it manually. The disclosure affordance and input are conflated into one element in the composition pattern. Source: https://www.radix-ui.com/primitives/docs/components/popover#trigger (verified 2026-05-04).
anatomy[empty-state] renamed Command.Empty (cmdk) cmdk ships `Command.Empty` which renders when no items match the current search query — a direct functional match for the canonical empty-state slot. However, cmdk does not add `role="status"` or `aria-live="polite"` to Command.Empty automatically; the "no results" state is not announced to assistive technology unless the consumer wraps the element in a live region. The canonical slot specifies `role="status"` for polite SR announcement. Source: https://github.com/pacocoursey/cmdk (verified 2026-05-04).
axes.properties[filterMode] omitted Neither Radix Popover nor cmdk expose a `filterMode` prop with the canonical enum (startsWith / contains / fuzzy / none). cmdk performs its own internal fuzzy-ranking filter on item values; the algorithm is not configurable via a prop. Consumers who need startsWith or exact-match filtering must disable cmdk's built-in filter (`filter={false}`) and implement the predicate themselves. Source: https://github.com/pacocoursey/cmdk (verified 2026-05-04).
axes.variants[multi-select] omitted cmdk has no built-in multi-select mode and Radix Popover has no selection concept at all. The canonical multi-select variant (accumulating an array of selected values, rendering chips, emitting `selectionChange` with `value: T[]`) is entirely absent. Consumers must build multi-select state management, chip rendering, and the chip-wrapping re-layout behaviour entirely from scratch. Source: https://github.com/pacocoursey/cmdk (verified 2026-05-04).
axes.variants[creatable] omitted Neither Radix Popover nor cmdk ship a creatable variant that allows the user to commit a free-text value not present in the option list. Implementing creatable requires the consumer to detect a "no match" state and render a synthetic "Create '<value>'" option, handle its selection as a new-value commit, and surface it correctly to assistive tech — all outside the scope of both primitives. Source: https://github.com/pacocoursey/cmdk (verified 2026-05-04).
axes.properties[async] omitted There is no async lifecycle primitive in either Radix Popover or cmdk. cmdk ships `Command.Loading` as a slot for displaying a loading indicator, but it manages no request state, debounce logic, or `aria-busy` lifecycle. The canonical `busy` data state with its `aria-live` announcement on result return is entirely a consumer responsibility. Source: https://github.com/pacocoursey/cmdk (verified 2026-05-04).
Why this audit reads the way it does

Radix Primitives ships no Combobox component. The URL https://www.radix-ui.com/primitives/docs/components/combobox returns 404 (verified 2026-05-04). The recommended Radix-ecosystem pattern for combobox behaviour is to compose Popover (for the floating portal and positioning layer) with either a hand-rolled listbox or the `cmdk` library (https://github.com/pacocoursey/cmdk), which is an accessible command-menu component that doubles as a combobox and internally uses Radix Dialog for its dialog variant. This composition pattern covers the mechanical skeleton — a positioned floating container, a filterable item list, and an empty state — but it leaves nearly every ARIA responsibility to the consumer: `role="combobox"` on the input, `aria-expanded`, `aria-controls`, `aria-activedescendant`, `role="listbox"` on the list, `role="option"` and unique ids on items. cmdk's own keyboard model does not update `aria-activedescendant` on an external input, making a fully spec-compliant combobox non-trivial to assemble. All anatomy divergences are therefore wholesale omissions or reshapings at the primitive level, not stylistic choices. Higher-level libraries in the Radix ecosystem (e.g. shadcn/ui's Combobox, which layers cmdk + Popover with manual ARIA wiring) fill this gap but are outside Radix Primitives itself.

react-aria ComboBox
import {
ComboBox,
ComboBoxItem,
ComboBoxValue,
Input,
Button,
Label,
Text,
FieldError,
Popover,
ListBox,
ListBoxItem,
} from 'react-aria-components';
// Single-select (default)
<ComboBox
name="country"
isRequired
onSelectionChange={(key) => console.log(key)}
onInputChange={(value) => console.log(value)}
>
<Label>Country</Label>
<Input />
<Button><ChevronDownIcon /></Button>
<Text slot="description">Start typing to filter.</Text>
<FieldError />
<Popover>
<ListBox>
<ListBoxItem id="au">Australia</ListBoxItem>
<ListBoxItem id="ca">Canada</ListBoxItem>
<ListBoxItem id="de">Germany</ListBoxItem>
</ListBox>
</Popover>
</ComboBox>
// Multi-select with TagGroup display
<ComboBox selectionMode="multiple" name="tags" defaultItems={items}>
<Label>Tags</Label>
<ComboBoxValue placeholder="Select tags" />
<Input />
<Button><ChevronDownIcon /></Button>
<Popover>
<ListBox>
{(item) => <ListBoxItem id={item.id}>{item.name}</ListBoxItem>}
</ListBox>
</Popover>
</ComboBox>
// allowsCustomValue (creatable)
<ComboBox allowsCustomValue name="language" defaultItems={languages}>
<Label>Language</Label>
<Input />
<Button><ChevronDownIcon /></Button>
<Popover>
<ListBox>
{(item) => <ListBoxItem>{item.name}</ListBoxItem>}
</ListBox>
</Popover>
</ComboBox>

Divergence

From Type → To Rationale
anatomy[clear-button] omitted React Aria ComboBox ships no built-in clear button. The docs and API reference contain no mention of a clear-button slot, prop, or sub-component. Consumers who need a clear affordance must compose their own button and wire it to the controlled `inputValue` / `value` props. The canonical `clear-button` slot (with `tabindex="-1"` and Escape binding) is entirely a consumer responsibility in React Aria. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
anatomy[trigger-button] reshaped <Button> — standard React Aria Button sub-component, always present in the compound tree The canonical `trigger-button` is marked optional (`required: false`) with `tabindex="-1"` so it does not appear in the keyboard tab order. React Aria ships a first-class `<Button>` sub-component inside the ComboBox compound tree that is expected to always be present and is not `tabindex="-1"` by default — the library manages focus routing differently (the button triggers the popover; focus stays on the input via `aria-activedescendant`). The trigger button is visible in all documented examples and the starter kits; it is not annotated as optional. Its icon content (ChevronDown) is consumer-composed children of the Button, not a built-in glyph. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
anatomy[listbox] reshaped <Popover><ListBox> — two nested sub-components replacing the single listbox slot The canonical anatomy collapses the floating popup and the listbox into a single `listbox` slot. React Aria separates them: `<Popover>` handles the floating positioning and portal mechanics (using floating-ui under the hood, syncing width via `--trigger-width` CSS custom property), while `<ListBox>` carries `role="listbox"` and the option collection. This split lets consumers swap the Popover (e.g. use a drawer on mobile) independently from the list surface. The divergence is structural: where the canon has one slot, React Aria has two co-required sub-components. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
anatomy[option] renamed ListBoxItem The canonical anatomy slot is named `option` (matching `role="option"`). React Aria names the sub-component `ListBoxItem` — the same component used inside standalone `ListBox`, giving a consistent API across collection surfaces (ListBox, ComboBox, Select, Autocomplete). The ARIA role produced on the DOM element is still `option`; only the React component name differs. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
anatomy[empty-state] omitted React Aria ComboBox has no dedicated empty-state slot or sub-component. The `allowsEmptyCollection` prop keeps the Popover open when the ListBox has zero items, but any "no results" message must be consumer-composed inside the `<ListBox>` children (e.g. a plain `<p>` or `<ListBoxItem isDisabled>`). No `role="status"` live-region is wired automatically. The canonical empty- state slot with `semantic: status` and polite live-region announcement is absent; consumers must implement the accessibility pattern themselves. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
axes.variants[single-select] reshaped selectionMode="single" (default, not a variant prop) The canonical documents `single-select` as a named variant. React Aria expresses selection mode as the `selectionMode` prop (`'single' | 'multiple'`) on the `<ComboBox>` root with `'single'` as the default. There is no `variant` prop; the selection-mode axis is orthogonal to any styling variant the consumer defines. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
axes.variants[multi-select] reshaped selectionMode="multiple" prop, with ComboBoxValue sub-component for display React Aria implements multi-select via `selectionMode="multiple"` on `<ComboBox>`. Selected values are displayed through the `<ComboBoxValue>` sub-component (which accepts a render-prop function receiving `{selectedItems, state}`) rather than as chips inside the input. The docs show integration with `<TagGroup>` to render selected items as tags — the tag rendering is consumer-composed, not built-in. `onChange` fires with `Key[]` when `selectionMode="multiple"`. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
axes.variants[creatable] renamed allowsCustomValue (boolean prop) The canonical `creatable` variant allows users to commit a value not present in the option list. React Aria expresses this as `allowsCustomValue: boolean` on the `<ComboBox>` root. When `allowsCustomValue` is true, the input value is submitted as text (overriding `formValue: 'key'`). The concept is one-to-one; only the surface name differs — it is a boolean prop rather than a variant enum value. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
axes.properties[filterMode] reshaped defaultFilter?: (textValue: string, inputValue: string) => boolean The canonical `filterMode` property is an enum (`startsWith | contains | fuzzy | none`). React Aria replaces this with a `defaultFilter` function prop that accepts a custom predicate, defaulting to a built-in case-insensitive contains filter. There is no built-in enum for filter modes; consumers wanting `startsWith` or `fuzzy` supply their own function. The power is greater but the API is not declarative — the library neither names nor exports the built-in filter strategies as named presets. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
axes.properties[strict] reshaped allowsCustomValue: false (default) — semantics inverted The canonical `strict: boolean` means "must select from the list" when `true`. React Aria inverts the polarity: `allowsCustomValue: false` (the default) is strict behaviour; `allowsCustomValue: true` opts into free-text. The canonical `strict: true` maps to React Aria's default (no prop needed); `strict: false` maps to `allowsCustomValue: true`. The behaviour is equivalent; the API shape and default sense are opposite. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
axes.properties[virtualised] omitted React Aria ComboBox ships no built-in virtualisation prop or infrastructure. Large option lists require the consumer to integrate `@tanstack/react-virtual` or a similar library inside the `<ListBox>` children. The canonical `virtualised` boolean prop and the ~200-item threshold guidance exist in the canon; neither has a React Aria API surface. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
axes.properties[async] reshaped Controlled items prop + useAsyncList hook from @react-stately/data The canonical `async: boolean` marks the filter as async (results arrive via Promise). React Aria has no `async` prop on `<ComboBox>`; instead, async loading is expressed architecturally: the consumer controls the `items` prop, manages loading state in their own state (or via `useAsyncList` from `@react-stately/data`), and supplies a `loadingState` indicator inside the ListBox themselves. The library provides no built-in busy-state announcement via `aria-live`; the consumer must compose this. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
axes.properties extended + `menuTrigger: 'input' | 'focus' | 'manual'` — controls when the Popover opens. `'input'` (default) opens on typing; `'focus'` opens when the input gains focus; `'manual'` requires a Button click or ArrowDown/ArrowUp with the popup closed. No equivalent in the canonical axes.properties. React Aria exposes `menuTrigger` as a first-class prop because the three trigger modes have meaningfully different UX and ARIA implications (whether `aria-expanded` changes on focus vs. on input vs. only on explicit activation). The canonical documents `trigger-button` as optional, implying a binary (button present / absent) rather than a three-way mode. React Aria makes the trigger semantics explicit and independently configurable. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
axes.properties extended + `selectionMode: 'single' | 'multiple'` — top-level prop driving both UI and event shape. When `'multiple'`, `onChange` fires with `Key[]` and `<ComboBoxValue>` is used to render selected items. No enum-level equivalent in the canonical axes.properties (the canonical surfaces single/multi as variants, not a property). React Aria unifies single and multi select under one `selectionMode` prop consistent with its ListBox / GridList / Table primitives. The canonical models these as structural variants (different component shapes). React Aria's approach enables runtime switching between modes without changing the compound tree structure — only the prop value changes. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
axes.properties extended + `formValue: 'text' | 'key'` — controls what value is submitted to the form. `'key'` (default) submits the selected item's `id`; `'text'` submits the display label. When `allowsCustomValue` is true, text is always submitted regardless of this setting. The canonical `formIntegration.submittedValue` states "strict mode submits the selected option's canonical value (id or token), not the display label" as prose guidance, but there is no canonical prop for toggling this behaviour at runtime. React Aria makes it a first-class prop because different back-ends need either the opaque key (API call) or the human-readable label (legacy form POST). This is a real API divergence, not just a naming difference. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
axes.properties extended + `validationBehavior: 'native' | 'aria'` — selects between HTML Constraint Validation API (`'native'`, default) and ARIA-only validation announcements (`'aria'`). `'native'` hooks into `reportValidity()` and the browser's built-in validation bubble; `'aria'` bypasses the native API and uses only `aria-invalid` + `aria-describedby` on `<FieldError>`. The canonical `formIntegration.validation` documents `setCustomValidity()` as the hook for strict-mode validation but does not model the native-vs-ARIA trade-off as a prop. React Aria surfaces this choice because native validation UX (browser tooltip) is inaccessible in some AT configurations; `'aria'` mode routes all validation messaging through the AT-visible `<FieldError>` instead. This is a React Aria platform-wide pattern also present in the Checkbox and Select implementations. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
axes.states.interactive[disabled] reshaped isDisabled prop (renders aria-disabled, not HTML disabled — consistent with React Aria platform contract) Consistent with React Aria's platform-wide accessibility-first disabled contract (same pattern as Button and Checkbox). `isDisabled` on `<ComboBox>` sets `aria-disabled` on the input and suppresses interactions without removing the element from the tab order. The HTML `disabled` attribute is never applied. Consumers needing full removal from tab order use `excludeFromTabOrder`. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
events[inputChange] renamed onInputChange One-to-one semantic match. React Aria adopts `on*` prefix for all event callbacks per React convention. `onInputChange(value: string)` fires on every keystroke and on programmatic mutation — matching the canonical `inputChange` payload description exactly. The callback signature is `(value: string) => void`. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
events[selectionChange] renamed onChange React Aria uses `onChange` rather than `onSelectionChange` for ComboBox (unlike its Select component which uses `onSelectionChange`). The payload is `Key | null` for single-select or `Key[]` for multi-select, matching the canonical description of `null / []` after clear. The canonical `selectionChange` concept maps exactly; the name is `onChange`. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
events[openChange] extended + `onOpenChange(isOpen: boolean, menuTrigger?: MenuTriggerAction)` — the second argument `menuTrigger` indicates what caused the open/close: `'input'`, `'focus'`, `'manual'`, or `undefined` on close. The canonical `openChange` payload is `boolean` only; React Aria adds the trigger-source discriminator. The extra `menuTrigger` argument lets consumers distinguish "opened by typing" from "opened by button click" from "opened on focus", which drives different UX responses (e.g. show all options on focus-open vs. filter on type-open). The canonical does not model trigger-source awareness. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
axes.properties[strict] extended + `validate?: (value: ComboBoxValidationValue<M>) => ValidationError | true | null` — declarative validation function replacing the imperative `setCustomValidity()` model. Works with both `validationBehavior` modes. The canonical documents `setCustomValidity` as the strict-mode hook; React Aria provides a declarative `validate` prop that the library invokes internally and routes through `<FieldError>`. React Aria's validation model is declarative-first (validate returns a message string or `true`) rather than imperative (`setCustomValidity(msg)`). This prevents the common mistake of forgetting to clear the custom validity message after correction. The trade-off: consumers cannot call `reportValidity()` imperatively without going through the form library integration points. Source: https://react-aria.adobe.com/ComboBox (fetched 2026-05-05)
Why this audit reads the way it does

React Aria ComboBox is a behaviour-only compound primitive that owns the accessibility contract (ARIA roles, aria-expanded, aria-activedescendant, form participation, keyboard event normalisation) while leaving visual slot anatomy and motion timing entirely to the consumer. The five deepest structural divergences from the canon are: 1. No built-in clear button — the canonical `clear-button` slot (with Escape binding and tabindex="-1") is absent. Consumers compose their own. 2. Single listbox slot → Popover + ListBox pair — React Aria separates floating positioning (Popover) from list semantics (ListBox), enabling the Popover to be swapped independently (e.g. bottom-sheet on mobile) without touching the collection surface. 3. filterMode enum → defaultFilter function — React Aria does not ship named filter presets; the consumer supplies a predicate. The built-in default is contains (case-insensitive), but startsWith and fuzzy require consumer code. 4. Variant props collapsed to selectionMode + allowsCustomValue — what the canon models as three named variants (single-select, multi-select, creatable) is expressed as two orthogonal props, matching React Aria's preference for composable boolean/enum props over variant enums. 5. formValue prop — React Aria adds a first-class toggle between submitting the item key vs. the display label, which the canonical only documents as prose guidance without an API hook. The rename surface (onInputChange, onChange, onOpenChange, allowsCustomValue, isDisabled, isRequired, isInvalid) follows React Aria's on*/is*/allows* naming scheme uniformly applied. The underlying AT-visible ARIA state (aria-expanded, aria-activedescendant, aria-controls, aria-required, aria-invalid, aria-disabled) is canonical-compliant.

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
Both

Variants, properties, states

Variants

Structurally different versions of the component.

single-select multi-select creatable

Properties

The same component, parameterised.

PropertyType
filterMode startsWith | contains | fuzzy | none
strict boolean
virtualised boolean
async boolean

States

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

KindStates
interactive
hoverfocus-visibleactivedisabled
data
closedopenbusyinvalid
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.
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.
Dev

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

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

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

Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe: /api/components/combobox/a11y-fixture.json

Dev

Common mistakes

Blocker

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

Blocker

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

Major

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

Major

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

Minor

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

Figma↔Code mismatches
  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.