Patterns Combobox / autocomplete

Combobox / autocomplete

A text input with a dynamic popup list. Users can type freely or pick from suggestions. The most-fudged ARIA pattern in 2026 — and the one with the most legitimate complexity.

Reviewed against the methodology checklist Updated

Try the keyboard

When to use

A combobox is the right answer when:

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

Accessible combobox (autocomplete)

Start typing to filter. Use the arrow keys to choose.

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.

HTML
<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:

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:

JS
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 keyboard model

JS
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:

Filtering and the live region

When the user types, filter the options and update the result count in the live region:

JS
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

HTML
<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”

HTML
<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:

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

Checklist

Screen reader transcript

Voiced by George — reading what VoiceOver on macOS Safari announces. Other readers (NVDA, JAWS) phrase things differently; the meaning is what matters.

Screen reader announcements for each step
ListenYou doScreen reader says
Tab onto the inputCity, edit, autocomplete, has popup, expanded, 22 results.
Press Down arrowAmsterdam, 1 of 22.
Type "lo"3 results. London, 1 of 3.
Press EnterLondon, selected. (input filled; popup closed)

Screen reader transcript — anti-pattern

Same interaction, but on the broken version above. Notice what the user is missing.

Screen reader announcements for each step
ListenYou doScreen reader says
Tab onto a div-based "combobox"City. (no role; SR has no idea this is interactive)
Type a few letters(autocomplete suggestions appear visually, no announcement)
Click a suggestion(selection happens; no confirmation)

WCAG references