When to use
A combobox is the right answer when:
- The user is choosing one value from a known set that’s too large for a
<select>to be comfortable (~20+ items). - You want them to be able to type to narrow down, not just scroll.
- The list can be filtered or fetched dynamically.
If your list has fewer than ~10 items, a <select> is simpler, smaller, and free. If users should be able to invent new values (tags, free-form), it’s still a combobox but with aria-autocomplete="none" or "both" instead of "list".
The pattern
Start typing to filter. Use the arrow keys to choose.
- Amsterdam
- Auckland
- Berlin
- Bogotá
- Cape Town
- Copenhagen
- Dublin
- Edinburgh
- Helsinki
- Lisbon
- London
- Melbourne
- Mexico City
- Montréal
- Mumbai
- New York
- Oslo
- Reykjavík
- Singapore
- Stockholm
- Tokyo
- Vancouver
The 2017+ ARIA 1.2 combobox is a real <input> element with a separate listbox popup, glued together with a few attributes. The listbox is not inside the input.
<label for="city">City</label>
<p id="city-hint">Start typing to filter. Use the arrow keys to choose.</p>
<input
type="text"
id="city"
role="combobox"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="city-list"
aria-describedby="city-hint"
autocomplete="off"
/>
<ul id="city-list" role="listbox" aria-labelledby="city" hidden>
<li role="option" id="opt-amsterdam" data-value="Amsterdam">Amsterdam</li>
<li role="option" id="opt-berlin" data-value="Berlin">Berlin</li>
…
</ul>
<p role="status" aria-live="polite" id="city-status"></p> The important wiring:
role="combobox"on the<input>— announced as “combobox” or “edit, autocomplete”.aria-autocomplete="list"— the popup contains suggestions; the input value is whatever they pick or type. ("both"if you also auto-complete the input value as they type;"none"for free-form with a list of recent values.)aria-expandedflips between"true"and"false"as the popup opens/closes.aria-controlspoints at the listbox.autocomplete="off"— turn off the browser’s own autocomplete, which would compete with yours.- The listbox has
role="listbox"withrole="option"children, each with a uniqueid. - A separate live region announces result counts as they change (“3 results”). This is essential — without it, screen-reader users have no signal that filtering is happening.
Active descendant, not focus
This is the part that trips up most implementations. Focus stays in the <input> the entire time — even as the user arrow-keys through the options. The “currently active” option is communicated via aria-activedescendant on the input:
function setActive(index) {
options.forEach((opt, i) => {
opt.setAttribute('aria-selected', String(i === index));
});
if (index >= 0) {
input.setAttribute('aria-activedescendant', options[index].id);
options[index].scrollIntoView({ block: 'nearest' });
} else {
input.removeAttribute('aria-activedescendant');
}
} Why? Because:
- The user is still typing. Moving DOM focus away from the input would interrupt typing.
- Tab should move focus out of the entire combobox, not down through option after option.
- Screen readers announce the active descendant when the attribute updates, so the user hears “London, 5 of 12” without focus actually moving.
The keyboard model
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (popupClosed) open();
setActive(activeIndex < last ? activeIndex + 1 : 0);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive(activeIndex <= 0 ? last : activeIndex - 1);
} else if (e.key === 'Home') {
e.preventDefault(); setActive(0);
} else if (e.key === 'End') {
e.preventDefault(); setActive(last);
} else if (e.key === 'Enter') {
if (activeIndex >= 0) {
e.preventDefault();
selectOption(activeIndex);
}
} else if (e.key === 'Escape') {
if (popupOpen) {
e.preventDefault();
close();
} else if (input.value) {
input.value = '';
filter();
}
}
}); Two subtleties:
- Tab is not preventDefault’d. Tab moves focus to the next field; the popup closes naturally.
- Escape behaves twice. First Escape closes the popup. If the popup is already closed, Escape clears the input. (APG calls this out specifically.)
Filtering and the live region
When the user types, filter the options and update the result count in the live region:
function filter() {
const q = input.value.trim().toLowerCase();
visible = allOptions.filter((opt) => {
const match = opt.dataset.value.toLowerCase().includes(q);
opt.hidden = !match;
return match;
});
status.textContent = visible.length === 0
? 'No matches.'
: `${visible.length} ${visible.length === 1 ? 'result' : 'results'}.`;
setActive(-1);
} The live region is what makes the combobox feel responsive to screen-reader users — without it, they’re typing into a void.
Anti-patterns
<select> styled to look like a combobox
If you can use a <select>, do — it’s the simplest and most accessible. But “combobox” specifically means typeable. A styled <select> can’t filter as the user types; the moment a designer asks for that, the answer is the pattern above, not a more elaborate <select> hack.
Nested input inside the listbox
<div role="combobox">
<input type="text" />
<ul role="listbox">…</ul>
</div> This is the ARIA 1.0 pattern; it was deprecated in 2017. Modern screen readers expect the role="combobox" to be on the input itself, with the listbox as a sibling controlled via aria-controls. Update if you find this in old code.
”It’s just an input with autocomplete”
<input list="cities" />
<datalist id="cities">
<option value="Amsterdam"></option>
<option value="Berlin"></option>
</datalist> <datalist> is OK for very simple cases — it’s a real combobox with native styling, and the announcement is reasonable in most screen readers. Limitations:
- You can’t customise the styling of the popup at all.
- You can’t customise filter behaviour (it always filters by prefix, not substring).
- You can’t load options dynamically (you can swap the entire datalist, but it’s clunky).
- You can’t render rich option content (no avatars, two-line items, etc.).
If those are fine, <datalist> is the right call. If not, the full pattern above.
No live region on filter
The most common omission. Filtering happens visually, but screen-reader users get no signal — they type three letters and have no idea anything happened. Always pair the combobox with a role="status" live region that updates with the result count.
Common variations
- Multi-select combobox — selected values appear as removable “chips” before the input. Requires
aria-multiselectable="true"on the listbox and individualaria-selectedtoggles. Substantially more complex; the APG multi-select combobox example (opens in new tab) is the reference. - Async loading — fetch on input. Throttle to ≥ 200ms. While loading, set
aria-busy="true"on the listbox and announce “loading…” in the status region. - Free entry (
aria-autocomplete="none") — input is the source of truth; the popup is a list of recent values or suggestions. Selecting fills the input but isn’t “the answer” — the user can keep typing. - Inline auto-complete (
aria-autocomplete="both") — as the user types, the rest of the most-likely match appears in the input, selected. Tab or Right arrow accepts; typing replaces. Tricky to get right; expect a lot of edge cases. - Disabled options — add
aria-disabled="true"to the option. Don’t usearia-hidden(that hides it from AT entirely) and don’t skip them with arrow keys (users can’t tell why they were skipped).
Checklist
-
<input>element withrole="combobox" - Real
<label>linked viafor(autocomplete attribute set as well —email,name, etc.) -
aria-autocomplete="list"(or"both"/"none"as appropriate) -
aria-expandedtoggles"true"/"false"as the popup opens/closes -
aria-controlspoints at the listbox - Listbox has
role="listbox"andaria-labelledbypointing back at the input - Each option has
role="option"and a uniqueid - Focus stays on the input. Active option communicated via
aria-activedescendant - Live region announces result counts as filtering happens
- Escape closes popup; second Escape clears input value
- Tab moves focus out of the combobox; doesn’t traverse options
- Click outside closes the popup without changing the value
- Selected option fills the input and closes the popup
-
autocomplete="off"so the browser’s own autocomplete doesn’t fight yours