Combobox
Type to filter a list — and, when the field allows it, commit a value that was never in the list. The control that turns a hundred options into three keystrokes.
Also called Autocomplete, Typeahead, Autosuggest, Searchable Select — in this system all of them are Combobox.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Combobox or Select
The crossover is around fifteen options. Below it, scanning a visible list beats typing; above it, the user is scrolling to find something they could have named in three letters.
Async options
While a request is in flight, the field says so. An empty panel during a fetch is indistinguishable from a query that matched nothing, and the user retypes.
Grouped options
Group headers survive filtering — a group with no remaining matches disappears entirely rather than sitting empty above a gap.
No matches
Name the query and offer the way forward. For an open combobox that means "Create «query»"; for a closed one it means saying plainly that nothing matched.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Type to filter. 24 regions available.
A text field that owns the keyboard, a panel that never takes focus, and a highlight tracked by aria-activedescendant.
- Input36px, identical to a text field
It is a real text input, which is why it can hold a caret and a partial query. That is the entire difference from a Select.
- Chevron16px, trailing
Distinguishes it from a plain text field. Without it, users do not know a list exists until they type something.
- Panel offset6px below
Close enough to read as attached, far enough that the input’s focus halo is not clipped.
- Panel heightmax 256px, ~8 rows
Fixed, so the panel does not resize as the query narrows and move the row under the pointer.
- Option row30px, 44px with a description
Dense, because the list is transient and scanned rather than acted on individually.
- Match highlightAccent tint on the substring
Explains why a row is in the list. Without it a fuzzy match reads as a random result.
- Active rowAccent fill, no focus
Tracked by aria-activedescendant. DOM focus stays in the input for the entire interaction.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-inset | — | Input fill |
| --ds-border-interactive | — | Input border |
| --ds-accent | — | Focus border |
| --ds-accent-subtle | — | Active row fill and match highlight |
| --ds-accent-text | — | Highlighted substring |
| --ds-surface-overlay | — | Panel surface |
| --ds-fg-muted | — | Descriptions, group headers, chevron |
Spacing
| Token | Value | Used for |
|---|---|---|
| panel offset | Gap between input and panel |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Input corners | |
| --radius-lg | Panel corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e4 | — | Panel elevation |
Motion
| Token | Value | Used for |
|---|---|---|
| debounce | Async option requests |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Type | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|
| Small | 32px | 0 30px 0 10px | 13px | — | — | — | Table filters and dense forms. |
| Medium | 36px | 0 34px 0 12px | 15px | 12rem | — | — | The default. |
| Large | 44px | 0 38px 0 14px | 16px | — | — | — | Touch layouts and single-field pages. |
| Panel | max 256px | — | — | — | Matches the input | — | About 8 rows. Fixed height so it never resizes as the query narrows. |
| Option | 30px | 0 8px | — | — | — | 44px on coarse pointers | 44px when a description is present, at every pointer type. |
role="combobox" aria-activedescendant="opt-3"onBlur → matched ? commit() : restore(lastValid)Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The match highlight must reach 4.5:1 as text on its tint — it is the part of the row the user is reading.
- The active row must be distinguishable from idle rows without relying on colour alone, since it is the only indication of what Enter will select.
- Option descriptions are content and owe 4.5:1, even though they read as secondary.
- The chevron owes 3:1 — it is the only signal that a list exists behind a field that otherwise looks like plain text input.
Keyboard
| ↓ | Opens the panel and moves the highlight to the first option. Never moves DOM focus. |
| ↑ / ↓ | Moves the highlight, wrapping at both ends. |
| Enter | Commits the highlighted option. With nothing highlighted, commits the typed text in an open combobox and does nothing in a closed one. |
| Esc | Closes the panel on the first press and clears the query on the second. |
| Tab | Commits the highlighted option and moves on. Tab must never leave a half-typed query behind. |
| Home / End | Moves the caret within the query, not the highlight — this is a text field first. |
| Alt + ↓ | Opens the panel without moving the highlight, so the full list can be reviewed. |
Screen readers
- Announce the option count after typing settles, debounced by about 300ms: "6 regions available".
- Each option announces its position: "Europe (London), 3 of 6". Group headers must not be announced as options.
- A no-match state must be announced. Silence is indistinguishable from a request that never fired.
Focus & touch
- DOM focus stays on the input from open to close. Committing an option keeps focus in the input with the value filled; Tab commits and moves on. Closing the panel must never send focus to the body.
- The on-screen keyboard covers the lower half of the screen, so the panel must render above the field when there is not room below — or, better, become a full-screen sheet on small viewports. Option rows go to 44px. Set inputmode appropriately: a numeric combobox should not open a full keyboard.
| Attribute | Applied to | Notes |
|---|---|---|
| role="combobox" | The input | With aria-expanded and aria-controls. This is the pattern; an input beside a div of results announces nothing. |
| aria-activedescendant | The input | The id of the highlighted option. This is what moves the screen-reader cursor without moving DOM focus. |
| aria-autocomplete="list" | The input | "both" only if you also inline-complete the text, which most fields should not. |
| role="listbox" / "option" | The panel and rows | With aria-selected on the highlighted row. Group headers must sit outside the options. |
| aria-live="polite" | A result count | "6 regions available". Without it a screen-reader user types and hears nothing. |
| aria-busy | The panel | While a request is in flight, so silence reads as loading rather than as no results. |
Example usage
1import { Combobox } from '@/ui/Select'23<Field label="Region" description="Type to filter. 24 regions available.">4 <Combobox5 options={regions}6 value={region}7 onChange={setRegion}8 onQueryChange={(q) => search(q)} // debounced inside9 loading={isFetching}10 aria-label="Region"11 />12</Field>1314// Async, with the two guards that matter: debounce the request, and drop15// responses that no longer match what the user has typed.16const [query, setQuery] = React.useState('')17const debounced = useDebounced(query, 300)1819React.useEffect(() => {20 if (!debounced) return21 const ac = new AbortController()22 fetchOptions(debounced, ac.signal).then(setOptions).catch(ignoreAbort)23 return () => ac.abort()24}, [debounced])2526// Closed combobox: unmatched text must not survive blur. Leaving it in the27// field looks like a selection and submits as nothing.28function onBlur() {29 const match = options.find((o) => o.label.toLowerCase() === query.toLowerCase())30 if (match) return onChange(match.value)31 setQuery(labelOf(value) ?? '') // restore the last valid selection32}Framework-free HTML
<div class="ds-combobox">
<input
id="region"
role="combobox"
aria-expanded="true"
aria-controls="region-list"
aria-activedescendant="region-opt-3"
aria-autocomplete="list"
aria-describedby="region-count"
autocomplete="off"
/>
<svg class="ds-combobox__chevron" aria-hidden="true">…</svg>
</div>
<ul id="region-list" role="listbox" aria-label="Regions">
<!-- Group headers sit OUTSIDE the options or they are announced as results. -->
<li role="presentation" class="ds-combobox__group">Europe</li>
<li id="region-opt-3" role="option" aria-selected="true">
Europe (<mark>Lon</mark>don)
<span class="ds-combobox__desc">11ms · 3 zones</span>
</li>
</ul>
<p id="region-count" class="sr-only" role="status" aria-live="polite">
6 regions available
</p>CSS
.ds-combobox { position: relative; }
.ds-combobox input {
inline-size: 100%;
block-size: 36px; /* identical to a text field: it is one */
padding-inline: 12px 34px;
border: 1px solid var(--ds-border-interactive);
border-radius: var(--radius-md);
background: var(--ds-surface-inset);
}
/* Without this the field looks like plain text input and nobody discovers
the list until they happen to type. */
.ds-combobox__chevron {
position: absolute;
inset-inline-end: 10px;
inset-block-start: 50%;
translate: 0 -50%;
pointer-events: none;
color: var(--ds-fg-muted);
}
.ds-combobox__panel {
position: absolute;
inset-inline: 0;
inset-block-start: calc(100% + 6px);
/* Fixed. A panel that shrinks as results narrow moves the row the pointer
is aiming at. */
max-block-size: 256px;
overflow-y: auto;
border-radius: var(--radius-lg);
background: var(--ds-surface-overlay);
box-shadow: var(--shadow-e4);
}
[role='option'][aria-selected='true'] {
background: var(--ds-accent-subtle);
color: var(--ds-fg);
}
/* The highlight explains WHY the row matched. */
[role='option'] mark {
background: var(--ds-accent-subtle);
color: var(--ds-accent-text);
}
@media (pointer: coarse) {
[role='option'] { min-block-size: 44px; }
}Component API
Combobox
| Prop | Type | Default | Description |
|---|---|---|---|
| options* | Option[] | — | The currently filtered set. For async fields this is whatever the last settled request returned. |
| value* | string | null | — | The committed value. Null means nothing is selected, which is distinct from an empty query. |
| onChange* | (v: string) => void | — | Fires on commit — Enter, click, or Tab on a highlighted option. |
| onQueryChange | (q: string) => void | — | For async option sets. Debounce inside, and drop stale responses. |
| loading | boolean | false | Shows a loading row. Never leave the panel silently empty during a fetch. |
| emptyText | string | 'No matches' | Should name the query. An open combobox shows "Create «query»" here instead. |
| size | 'sm' | 'md' | 'lg' | 'md' | Matches the shared control scale. |
Professional tips
- Sort exact prefix matches above substring matches. Typing "us" should put "US East" above "Australia" even though both contain the letters.
- Show the full list on focus before anything is typed. It tells the user what kind of thing goes in the field, which a blank panel does not.
- Cache results per query string. Backspacing through a query should not re-request every intermediate state.
- For an open combobox, put "Create «query»" as the last row rather than the first — the user is looking for an existing value first.
- Preserve the caret when you programmatically set the input value. Naive implementations jump it to the end on every keystroke.
Performance
- Debounce async requests by about 300ms and cancel in-flight ones with an AbortController. Without cancellation a fast typist has six requests racing to render.
- Filter client-side under about 1,000 options. A round trip to filter a list already in memory is latency for nothing.
- Virtualise past roughly 200 visible rows, and add aria-setsize and aria-posinset when you do.
- Memoise the filtered list on the query and the option array. Re-filtering on every render is the usual cause of a laggy combobox.
Common mistakes
- Moving DOM focus into the list, so typing after the first arrow key goes nowhere.
- Leaving unmatched text in a closed field, which looks selected and submits as nothing.
- No loading state, so a slow fetch is indistinguishable from no results.
- A panel that resizes as results narrow, moving the row under the pointer.
- Prefix-only matching, which fails on "york" for "New York".
- Group headers marked up as options, so they are announced as selectable results.
- No result count in a live region, leaving screen-reader users with no feedback at all.
Real-world recommendations
- The crossover from Select to Combobox is around fifteen options, but it depends on familiarity — users know country names and will type them; they do not know your internal region codes and will scroll.
- Country and timezone pickers are the canonical case, and both need synonym matching: "USA", "United States" and "US" must all find the same row.
- For async fields, show the last successful results while a new request is in flight rather than emptying the panel. Perceived speed comes from never showing nothing.
- If users routinely type a value that is not in your list, that is data telling you the list is incomplete — not that you need an open combobox.