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.
Implementations
How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.
MatAutocomplete / MatAutocompleteTrigger (from @angular/material/autocomplete) 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.
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">⌄</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.
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.
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.
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-select multi-select creatable 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
Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe:
/api/components/combobox/a11y-fixture.json
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.