Accessible comboboxes and autocomplete inputs
Build accessible autocomplete inputs with the ARIA combobox pattern, listbox suggestions, keyboard navigation, active options, and async loading.
Overview
What: An editable combobox is a text input with an associated popup. In the listbox version shown here, DOM focus stays on the input while aria-activedescendant identifies the active suggestion.[1]
Why: A visual suggestion menu without matching semantics and keyboard behavior can fail to communicate whether the popup is open, which options are available, or which option is active.
Use: Use this pattern when people need editable text entry plus suggestions, such as a city search or product lookup.
For a small static set, prefer a native <select> unless the task needs editable text entry. Native <datalist> can suit lightweight suggestions, but browsers differ in presentation, assistive-technology behavior, and styling control. A custom combobox is justified only when the extra behavior is worth the implementation and testing cost.
Token inputs, command palettes, and multiselect pickers need additional patterns. Do not treat this single-select example as a complete implementation for them.
Implementation
1. Start with a named input and controlled popup
<div id="city-combobox">
<label for="city-input">City</label>
<input
id="city-input"
type="text"
role="combobox"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="city-listbox"
autocomplete="address-level2"
/>
<ul
id="city-listbox"
role="listbox"
aria-label="Matching cities"
hidden
></ul>
<p id="city-status" class="visually-hidden" role="status"></p>
</div>
The label supplies the combobox's accessible name. aria-expanded reports popup state, and aria-controls references the popup. It does not move or own the popup in the DOM.
Do not add aria-activedescendant until there is an active, rendered option. Remove it before that option is removed or the popup closes.
2. Choose the popup role deliberately
The APG permits a combobox popup to be a listbox, grid, tree, or dialog.[1]
- Listbox: Plain suggestions. This is the pattern implemented below.
- Grid: Suggestions need a row-and-column structure.
- Tree: Suggestions have a hierarchical structure.
- Dialog: The popup is genuinely a dialog with controls of its own, such as a complex date picker.
For listbox, grid, and tree popups, DOM focus remains on the combobox and aria-activedescendant identifies the active item. For a dialog popup, move DOM focus into the dialog and follow the dialog's focus rules. Do not use aria-activedescendant to point into a dialog.[1]
A listbox's selectable items use role="option". Do not place arbitrary buttons and links inside options. If every result needs its own controls, choose a different popup design.
3. Render suggestions with stable IDs
Use stable application IDs rather than array positions that change after each filter. Set text through textContent so suggestion labels are not treated as HTML.
const root = document.getElementById('city-combobox');
const input = document.getElementById('city-input');
const listbox = document.getElementById('city-listbox');
const status = document.getElementById('city-status');
const cities = [
{ id: 'portland-me', label: 'Portland, Maine' },
{ id: 'portland-or', label: 'Portland, Oregon' },
{ id: 'portsmouth-nh', label: 'Portsmouth, New Hampshire' }
];
let matches = [];
let activeIndex = -1;
function setOpen(open) {
listbox.hidden = !open;
input.setAttribute('aria-expanded', String(open));
if (!open) clearActive();
}
function clearActive() {
activeIndex = -1;
input.removeAttribute('aria-activedescendant');
listbox.querySelectorAll('[role="option"]').forEach((option) => {
option.classList.remove('active');
option.setAttribute('aria-selected', 'false');
});
}
function selectCity(city) {
input.value = city.label;
setOpen(false);
input.focus();
}
function renderOptions() {
listbox.replaceChildren();
for (const city of matches) {
const option = document.createElement('li');
option.id = `city-option-${city.id}`;
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', 'false');
option.textContent = city.label;
// Keep a mouse press from blurring the input before click runs.
option.addEventListener('mousedown', (event) => event.preventDefault());
option.addEventListener('click', () => selectCity(city));
listbox.append(option);
}
}
function updateSuggestions(value, { showAll = false } = {}) {
const query = value.trim().toLocaleLowerCase();
matches = showAll
? [...cities]
: query
? cities.filter((city) => city.label.toLocaleLowerCase().includes(query))
: [];
clearActive();
renderOptions();
setOpen(matches.length > 0);
}
This example keeps selection and the active visual option together while the popup is open. If your product distinguishes an active suggestion from a committed selection, model those states separately instead of using aria-selected as a styling hook.
4. Keep DOM focus on the input
Arrow keys change the active descendant, not DOM focus. Enter accepts the active suggestion. Escape closes the popup without accepting it. Tab closes the popup and continues through the page without being trapped.[1]
function setActive(index) {
const options = [...listbox.querySelectorAll('[role="option"]')];
const option = options[index];
if (!option) return;
activeIndex = index;
options.forEach((item, itemIndex) => {
const active = itemIndex === index;
item.classList.toggle('active', active);
item.setAttribute('aria-selected', String(active));
});
input.setAttribute('aria-activedescendant', option.id);
option.scrollIntoView({ block: 'nearest' });
}
input.addEventListener('keydown', (event) => {
// Do not turn an IME composition keystroke into a combobox command.
if (event.isComposing) return;
if (event.altKey && event.key === 'ArrowDown') {
event.preventDefault();
updateSuggestions(input.value, { showAll: true });
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (listbox.hidden) {
updateSuggestions(input.value, { showAll: !input.value.trim() });
}
if (!matches.length) return;
event.preventDefault();
const next = event.key === 'ArrowDown'
? (activeIndex < 0 ? 0 : Math.min(activeIndex + 1, matches.length - 1))
: (activeIndex < 0 ? matches.length - 1 : Math.max(activeIndex - 1, 0));
setActive(next);
return;
}
if (event.key === 'Enter' && !listbox.hidden && activeIndex >= 0) {
event.preventDefault();
selectCity(matches[activeIndex]);
return;
}
if (event.key === 'Escape' && !listbox.hidden) {
event.preventDefault();
setOpen(false);
return;
}
if (event.key === 'Tab') {
setOpen(false);
}
});
This example clamps Arrow keys at the first and last options. Wrapping is also possible. Pick one behavior, document it, and test it.
Escape preserves the user's typed text. Rolling back to an earlier value is a separate cancel behavior and should not happen silently.
5. Filter without breaking text entry
IME composition can emit input and key events before the user has finished composing a character. KeyboardEvent.isComposing reports whether a keyboard event occurs during that composition session.[8]
input.addEventListener('input', (event) => {
if (event.isComposing) return;
updateSuggestions(input.value);
});
input.addEventListener('compositionend', () => {
updateSuggestions(input.value);
});
document.addEventListener('pointerdown', (event) => {
if (!root.contains(event.target)) setOpen(false);
});
Do not override normal editing keys such as Left, Right, Home, End, Backspace, or platform text-editing shortcuts.
6. Treat status messages as optional feedback
A result-count or loading announcement can help, but announcing every keystroke can compete with active-option speech. Add a concise, debounced status only when testing shows that users need it.
Keep status content outside the listbox so the listbox contains the expected option structure.
function setLoading(loading) {
input.setAttribute('aria-busy', String(loading));
status.textContent = loading ? 'Loading suggestions' : '';
}
For asynchronous data:
- Stamp requests or abort the previous request.
- Ignore stale responses.
- Clear
aria-activedescendantbefore replacing the referenced option. - Keep
aria-expandedsynchronized with the rendered popup. - Do not announce partial results as final.
7. Use the autocomplete value that describes the behavior
aria-autocomplete supports none, inline, list, and both.[1]
Use list for the popup behavior shown here. Inline completion is valid when the suggested suffix is visibly selected and continued typing replaces it. It needs its own browser and assistive-technology testing; do not reject it or adopt it by rule of thumb.
8. Support pointer and touch input
Each option needs a clear hit area and a visible active or hover state that is not the only way to discover it. Test with a real mobile keyboard. Keep the popup in the visible viewport, and do not scroll the page simply because the active descendant changed.
If a blur handler closes the popup, make sure it cannot run before a pointer selection is committed. The mousedown and click handling in the sample demonstrates one approach for mouse input; test touch and stylus behavior separately.
Verification
- The input has a visible label and an accessible name.
aria-expandedmatches the popup's visible state.aria-controlsreferences the current popup.- Typing filters the list without moving DOM focus away from the input.
- During IME composition, Enter, Escape, and filtering do not interrupt text entry.
- Arrow keys move a visible active option; the chosen boundary behavior is consistent.
- When present,
aria-activedescendantreferences one rendered option with a stable ID. - The active descendant is removed before its option is removed or the popup closes.
aria-selectedrepresents the implementation's actual selection model, not only a CSS convenience.- Enter accepts the active option without submitting the surrounding form unexpectedly.
- Escape closes without accepting the active option or erasing typed text.
- Tab closes the popup and moves on without trapping focus.
- Mouse, touch, and keyboard selection produce the same value.
- Any count or loading announcement is concise and does not cover active-option feedback.
- Async responses cannot replace newer results.
- Browser and screen-reader testing covers the combinations the product supports.
Automated checks can catch invalid relationships and missing required roles. They cannot prove that the interaction is understandable or that announcements arrive in a useful order.
Limitations
- Multiselect token or chip inputs need removal controls, clear labels, and a more complex selection model. Ship and test that as a separate pattern.
- Virtualized results can work, but the active descendant must remain rendered and uniquely identified while it is referenced. Update or remove the reference before unmounting that option.
- A dialog-popup combobox has a different focus model from this listbox example.
- Browser autofill and a custom suggestion popup can compete. Test the chosen HTML
autocompletetoken in the actual form. - Very large or remote datasets need loading, error, empty, and stale-response states in addition to the happy path.
Related guides
- Use this widget within a sound landmark structure
- When the combobox is inside a modal dialog
- Avoid unlabeled custom widgets
Sources
[1] WAI-ARIA APG: Combobox Pattern [8] MDN: KeyboardEvent.isComposing