Designer view
Combobox
A text input combined with a popup list of suggestions. The user types to filter, navigates with arrow keys, and selects an option to commit a value. Distinct from a Select (which has no text input) and from Autocomplete (which is a behavior, not a component name in most design systems).
When to use
Use
When the user selects a value from a finite or near-finite set that benefits from typeahead filtering. The text-input front differentiates it from `Select`: users can type to narrow before committing. Best for option counts of ~10–10,000 with optional async filter.
Avoid
For free-text input with no constrained set of values — that is `Input` or `SearchInput`. For a fixed short list (≤7) where typeahead adds no value — `Select`. For multi-tag input where the set is open-ended and tags are user-created — `TagInput`.
Versus related
- select
`Select` has no text-input front; the user picks from a fixed list via click or keyboard. `Combobox` adds a typeahead input plus async and strict modes. Migrate from Select to Combobox when the option count crosses the "scrollable popup is annoying" threshold (~10 items in practice).
- search-input
`SearchInput` is free text submitted to a search engine; results appear separately. `Combobox` constrains output to a known set of options and commits on selection. They look similar; the distinguisher is whether the popup is a completion list (Combobox) or a results preview (SearchInput).
- tag-input
`TagInput` accepts user-created tags or selections from a set and renders accumulated tags inline. `Combobox` with `variant: multi-select` is the constrained-set version; `TagInput` is the open-set version where users can create new tags.
Figma anatomy
| Slot | Figma type | Hint |
|---|---|---|
input | instance | Text input component instance; min-width drives the field size |
clear-button | instance | Icon button instance; visibility bound to "has value" property |
trigger-button | instance | Icon button instance; chevron rotates with `aria-expanded` |
listbox | frame | Floating frame (overlay layer); width matches input by default |
option | instance | Option component instance with selected and highlighted variants |
empty-state | frame | Centered text with optional icon; visibility bound to "no matches" state |
Token usage per slot
input- spacing
- padding
spacing.compact
- padding
- radius
- corner
radius.md
- corner
- color
- background
color.surface.bg - foreground
color.text.primary - border
color.border.strong - ring
color.border.focus
- background
- typography
- size
text.md
- size
clear-button- spacing
- padding
spacing.tight
- padding
- radius
- corner
radius.sm
- corner
- color
- foreground
color.text.muted
- foreground
trigger-button- spacing
- padding
spacing.tight
- padding
- radius
- corner
radius.sm
- corner
- color
- foreground
color.text.muted
- foreground
listbox- spacing
- padding
spacing.tight
- padding
- radius
- corner
radius.md
- corner
- color
- background
color.surface.raised - border
color.border.subtle
- background
- elevation
- shadow
elevation.lg
- shadow
option- spacing
- padding
spacing.compact
- padding
- radius
- corner
radius.sm
- corner
- color
- foreground
color.text.primary
- foreground
- typography
- size
text.md
- size
empty-state- spacing
- padding
spacing.comfortable
- padding
- color
- foreground
color.text.muted
- foreground
- typography
- size
text.sm
- size
Figma ↔ Code property map
| Figma | Type | Code | Notes |
|---|---|---|---|
Variant | Variant | variant | Maps single-select / multi-select / creatable. |
Filter Mode | Variant | filterMode | startsWith / contains / fuzzy / none. Figma encodes for documentation; code drives the filter function selection. |
State | Variant | data-state | Figma exposes closed / open / busy / invalid as a Variant for preview. In code these are data states toggled by application logic, mirrored on the host as a `data-state` attribute. |
Has Clear Button | Boolean | clear | Slot-visibility toggle; code conditionally renders the clear-button slot when the input has a value. |
Has Trigger Button | Boolean | trigger | Optional disclosure affordance; code conditionally renders the chevron trigger. |
Strict | Boolean | strict | — |
Async | Boolean | async | In Figma toggles the busy-state preview. In code marks the filter as async (results arrive via a Promise / observable). |
Virtualised | Boolean | virtualised | Documentation-only in Figma; in code drives the listbox-rendering strategy above ~200 options. |
Placeholder | Text | placeholder | — |
Motion
| Transition | Duration token |
|---|---|
open | motion.duration.fast |
close | motion.duration.fast |
filter | motion.duration.instant |
Responsive behaviour
| Breakpoint | Change |
|---|---|
breakpoint.sm | At and below, the floating listbox is replaced with the platform bottom-sheet picker (`<select>` on iOS, drawer-style native list on Android). The trigger button opens the OS-level surface; the in-page listbox markup is suppressed and `aria-controls` no longer applies. Filtering becomes synchronous (no async results) and the chip-input multi-select degrades to comma-separated tokens in the input. |
breakpoint.md | Above this width, the floating listbox renders as authored — `aria-activedescendant` model, `aria-controls` reference, all framework-map mechanisms apply. Multi-select chips wrap inline with the input. |
Internationalisation
RTL · mirroring
Clear-button and trigger-button move from inline-end of the input to inline-start (visual right→left flip) via logical positioning. Chevron rotation is *not* mirrored — the rotation indicates open/closed state, not direction. Listbox aligns to the input's inline-start edge (same logical anchor in both directions). Input caret follows document direction; mixed-direction input (typing Hebrew into a Latin-default field) honours the input's own `dir` attribute.
Text expansion
Option labels can grow significantly; listbox width matches the input by default and may need a max-width override when option labels exceed the input width. Truncation with ellipsis is the canonical fallback for option labels exceeding the listbox width; aria-label on the option provides the full text for SR users. Placeholder text is the riskiest expansion target — many locales' "Search…" translations exceed 30% length.
Variants, properties, states
Variants
Structurally different versions of the component.
single-selectmulti-selectcreatable Properties
The same component, parameterised.
| Property | Type |
|---|---|
filterMode | startsWith | contains | fuzzy | none |
strict | boolean |
virtualised | boolean |
async | boolean |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactivedisabled |
data | closedopenbusyinvalid |
State transitions
| From | To | Trigger |
|---|---|---|
closed | open | User types a printable character into the input, presses Down/Up arrow with the popup closed, or clicks the trigger button. `aria-expanded` flips to `true`; the listbox renders. |
open | closed | User presses Escape with the popup open, clicks outside the combobox, or selects an option (single-select). `aria-expanded` flips to `false`; the listbox is removed. |
open | busy | Typed input triggers an async filter request (`async: true`). Existing options are replaced with a loading affordance; an `aria-live` announcement signals the in-flight state. |
busy | open | Async filter results return successfully. The option list re-renders with the new matches; an `aria-live` announcement signals the result count. |
open | invalid | `strict: true` and the input blurs with a value that does not match any option. The combobox surfaces an inline error and sets `aria-invalid="true"` on the input. |
invalid | closed | User clears the input or selects a valid option from a subsequently-opened listbox. `aria-invalid` is removed; the inline error is cleared. |
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.
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.
Accessibility hints
| 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. |