All resources
EngineeringMar 2026·11 min read·By enabl team
Headless UI patterns: accessible combobox in 90 LOC
Most combobox bugs are ARIA bugs. Here's a minimal, dependency-free pattern that satisfies the WAI-ARIA Authoring Practices and the realities of NVDA/VoiceOver.
The combobox is the single most failure-prone component in the WAI-ARIA Authoring Practices. Most teams either over-engineer it or ship a div with onClick handlers.
The five things you must get right
- 01Input is role="combobox" with aria-expanded, aria-controls, aria-autocomplete="list".
- 02Listbox is role="listbox" referenced by aria-controls.
- 03Active option is tracked with aria-activedescendant on the input — focus stays on the input.
- 04Arrow keys move the active option; Enter selects; Escape closes.
- 05Status of "3 results" goes through an aria-live="polite" region, not a toast.
<div>
<label htmlFor="city">City</label>
<input
id="city"
role="combobox"
aria-expanded={open}
aria-controls="city-listbox"
aria-autocomplete="list"
aria-activedescendant={activeId}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKey}
/>
<ul id="city-listbox" role="listbox" hidden={!open}>
{results.map((r, i) => (
<li
key={r.id}
id={`opt-${r.id}`}
role="option"
aria-selected={i === activeIndex}
onMouseDown={() => choose(r)}
>
{r.label}
</li>
))}
</ul>
<div role="status" aria-live="polite" className="sr-only">
{results.length} results
</div>
</div>Common pitfalls
- Moving DOM focus into the listbox — breaks typing. Use aria-activedescendant instead.
- Forgetting onMouseDown vs onClick — onClick fires after blur and the popup closes.
- Announcing every keystroke. Debounce the live region or you'll drown the user.