Forms & Input
Combobox
AvailableA text input combined with a suggestion list (autocomplete). One of the most complex patterns in the APG.
Specification updated
- Enhancement#3311(opens in a new tab)Keyboard Interface Practice: Add note on focus management for pointer activation
What Is a Combobox?
A combobox is a UI that combines a text input with a popup list of suggestions that appears as the user types. It is commonly used for search autocomplete, address and state lookups, tagging, and similar interactions.
It is essentially a fusion of a free-text input and a selectable listbox. Because of this, communicating state (whether the popup is open, which option is highlighted) is complex, making it one of the most challenging patterns in the APG.
Why Does Accessibility Matter?
- Keyboard-only users. They need ↓ to move into the suggestions,↑↓ to navigate, Enter to confirm, and Esc to dismiss. Plain
<div>suggestions are mouse-only — keyboard users cannot select them. - Screen reader users. They need to hear "Search states, edit, combobox, collapsed" for the role and state, "X results available" when suggestions appear, and "California, selected, 4 of 12" as they navigate — conveying the count and the highlighted position.
This is achieved through role="combobox" + aria-expanded +aria-controls on the input, role="listbox" on the popup, andaria-activedescendant to communicate the highlighted option.
Live Demo (Recommended Implementation)
A combobox for searching US states. As you type, suggestions are filtered. Try operating it without a mouse — use the arrow keys, Enter, and Esc.
- Alabama
- Alaska
- Arizona
- California
- Colorado
- Florida
- Georgia
- New York
- Oregon
- Texas
- Virginia
- Washington
Try it: type to see suggestions → ↓ to enter the list → ↑ ↓ to navigate → Enter to confirm → Esc to dismiss. Try filtering with 'Cal', 'New', 'Al', etc.
Tip
Focus stays on the input at all times. Instead of moving focus,aria-activedescendant tells assistive technology which option is currently highlighted. This lets users keep typing while selecting from the list. The open/close state is conveyed through aria-expanded and the result count viaaria-live.
Keyboard Interaction
| Key | Action | Priority |
|---|---|---|
| Text input | Filter suggestions and open the popup | Required |
| ↓ | Open the popup / move highlight to the next suggestion | Required |
| ↑ | Move highlight to the previous suggestion | Required |
| Enter | Accept the highlighted suggestion and close | Required |
| Esc | Close the popup | Required |
| Esc (when popup is closed) | Clear the input | Optional |
| Home / End | Move the cursor within the input field (standard text editing) | Optional |
Required WAI-ARIA Roles and Properties
| Target | Attribute / Role | Meaning |
|---|---|---|
| Input field | role="combobox" | Indicates a composite widget combining text input with a popup. |
| Input field | aria-expanded="true | false" | Indicates whether the popup is open. Must be updated on every open/close. |
| Input field | aria-controls="listbox-id" | Identifies which popup (listbox) this input controls. |
| Input field | aria-activedescendant="option-id" | Points to the currently highlighted option. Updated on each move; removed when closed. |
| Input field | aria-autocomplete="list" | Indicates that suggestions are provided via a list based on input. |
| Popup | role="listbox" + name | Container for the suggestions. Named via aria-labelledby or similar. |
| Each suggestion | role="option" + unique id + aria-selected | A single suggestion. aria-selected="true" when highlighted. |
| Live region | aria-live="polite" | Announces changes such as the number of available suggestions (optional but recommended). |
Implementation: Recommended Pattern (Good)
Good / Recommended
Give the input role="combobox" and manage aria-expanded/aria-controls/aria-activedescendant. The popup uses role="listbox" > role="option".
Markup:
<label id="cmb-label" for="cmb-input">Search states</label>
<input
id="cmb-input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="cmb-list"
aria-autocomplete="list"
autocomplete="off"
>
<ul id="cmb-list" role="listbox" aria-labelledby="cmb-label" hidden>
<li role="option" id="cmb-opt-0">Alabama</li>
<li role="option" id="cmb-opt-1">Alaska</li>
<!-- ... -->
</ul>
<!-- Live region for announcing the number of results -->
<p class="sr-only" aria-live="polite"></p>Script for filtering, open/close, highlighting, and selection:
const input = document.getElementById('cmb-input');
const listbox = document.getElementById('cmb-list');
const options = [...listbox.querySelectorAll('[role="option"]')];
let activeIndex = -1;
const visible = () => options.filter((o) => !o.hidden);
const open = () => {
input.setAttribute('aria-expanded', 'true');
listbox.hidden = false;
};
const close = () => {
input.setAttribute('aria-expanded', 'false');
listbox.hidden = true;
activeIndex = -1;
input.removeAttribute('aria-activedescendant');
options.forEach((o) => o.setAttribute('aria-selected', 'false'));
};
// Communicate the currently highlighted option via aria-activedescendant
const setActive = (i) => {
const vis = visible();
options.forEach((o) => o.setAttribute('aria-selected', 'false'));
if (i < 0 || i >= vis.length) {
activeIndex = -1;
input.removeAttribute('aria-activedescendant');
return;
}
activeIndex = i;
vis[i].setAttribute('aria-selected', 'true');
input.setAttribute('aria-activedescendant', vis[i].id);
vis[i].scrollIntoView({ block: 'nearest' });
};
// Filter options based on input and announce the count
const filter = () => {
const q = input.value.trim();
options.forEach((o) => {
o.hidden = q !== '' && !o.textContent.includes(q);
});
return visible().length;
};
input.addEventListener('input', () => {
filter() > 0 ? open() : close();
setActive(-1);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (input.getAttribute('aria-expanded') !== 'true' && filter() > 0) open();
const n = visible().length;
if (!n) return;
setActive(e.key === 'ArrowDown'
? (activeIndex + 1) % n
: (activeIndex - 1 + n) % n);
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
input.value = visible()[activeIndex].textContent;
close();
} else if (e.key === 'Escape') {
input.getAttribute('aria-expanded') === 'true' ? close() : (input.value = '');
}
});
options.forEach((opt) => opt.addEventListener('click', () => {
input.value = opt.textContent;
close();
input.focus();
}));Note
The key principle is "never move focus away from the input." Option highlighting is handled virtually via aria-activedescendant, and state is conveyed through aria-expanded plus the result count announced viaaria-live. This is how typing and selection work together seamlessly.
Anti-pattern (Bad)
Below is a plain <input> with <div> suggestions.You can click options with a mouse, but keyboard users cannot navigate to them, and screen readers are unaware of the suggestions, count, or selection.
Try it: typing shows suggestions, but pressing ↓ doesn't move to them, and Enter doesn't select anything. Screen readers have no idea suggestions have appeared.
<!-- ❌ Anti-pattern: plain input + div suggestions -->
<input type="text" placeholder="Enter a state">
<div class="suggestions">
<!-- No roles, no ARIA. Can't select options with keyboard -->
<div class="item" onclick="pick(this)">California</div>
<div class="item" onclick="pick(this)">New York</div>
</div>Bad / Avoid
Problems with this implementation:
- Can't select options via keyboard — suggestions are
divelements with no arrow key or focus support. - No roles or state — missing
role="combobox"/aria-expanded/aria-controls, so the open/close state and relationship are not communicated. - No highlight indication — missing
aria-activedescendant/aria-selected, so there is no way to know which option is current. - No result count — missing
aria-live, so "X results available" is never announced.
Implementation Checklist
- The input has role="combobox" with aria-expanded and aria-controls
- The popup uses role="listbox" containing role="option" elements (each with a unique id)
- Highlight is conveyed via aria-activedescendant and aria-selected, updated on each move
- aria-expanded always matches the open/close state
- Down arrow opens suggestions, Up/Down arrows navigate, Enter accepts, Escape closes
- Typing filters suggestions and the count is announced via aria-live (recommended)
- Focus remains on the input field and focus indicators are visible
Field Notes — Where practice diverges from the APG
aria-selected marks committed selection, not the moving highlight
- The APG says
- In the APG combobox examples, aria-selected="true" moves together with aria-activedescendant: each arrow-key highlight also marks that option as selected.
- What practice does
- React Aria and Base UI apply aria-selected only to the committed value. The keyboard highlight is conveyed by aria-activedescendant (and visual styling) alone; aria-selected="true" appears only once the user commits, e.g. with Enter.
- Why
- Focus ("the option I am pointing at") and selection ("the value I committed") are different concepts. Moving aria-selected with the highlight makes screen readers announce "selected" on every arrow key, misleading users into thinking a value was committed — and in multi-select UIs the meaning of "selected" becomes ambiguous. Since aria-activedescendant already conveys focus, the field practice reserves aria-selected for actual selection state.
Adopted by:React Aria、Base UI
Sources:React Aria — useComboBox(opens in a new tab)、Base UI — Combobox(opens in a new tab)
On mobile, drop role="combobox" for a role="searchbox" inside a tray
- The APG says
- The input has role="combobox" tied to the listbox via aria-expanded / aria-controls, with arrow keys as the primary way to move between options.
- What practice does
- React Aria's ComboBox replaces the outer input with a button on mobile; tapping opens a full-height tray containing a search input — with role="searchbox", not combobox.
- Why
- Device testing showed that keeping role="combobox" on the tray input made screen readers announce "double tap to close" incorrectly. Touch screen reader users also have no physical arrow keys — the APG's primary navigation model simply does not exist for them. Combined with the state-management complexity of duplicated inputs, the team designed mobile as a different UI. A canonical case of prioritizing per-input-method usability over pattern fidelity.
Adopted by:React Aria、React Spectrum
Sources:React Aria Blog — Creating an accessible autocomplete experience(opens in a new tab)
The listbox cannot live next to the input — portals plus dynamic aria-hidden fill the gap
- The APG says
- APG examples place the listbox as a DOM sibling of the combobox, assuming screen readers can move naturally between input and list.
- What practice does
- React Aria portals the listbox to the end of the document to escape overflow clipping. To restore the broken assumption it walks the DOM with a TreeWalker, applies aria-hidden to everything except the input and listbox, and keeps watching mutations with a MutationObserver while open.
- Why
- Real apps put comboboxes inside scroll containers where an adjacent listbox gets clipped. Portalling fixes the visual problem but breaks reading order by putting DOM distance between input and list. React Aria's answer — temporarily hiding everything else from assistive technology — recreates the screen reader experience the APG structure was designed to produce, by other means. Porting the intended experience, not the textbook DOM.
Adopted by:React Aria、React Spectrum
Sources:React Aria Blog — Creating an accessible autocomplete experience(opens in a new tab)
Source (English):Combobox Pattern — W3C APG(opens in a new tab)