What a Combobox Teaches You About Product Quality
Building a WCAG-compliant combobox forces keyboard, screen reader, and edge case decisions that reveal how much you really know about your UI.
A combobox is the shortest path from "we care about accessibility" to "prove it." The ARIA 1.2 Authoring Practices Guide defines exactly what a combobox must do — keyboard navigation, live region announcements, focus management, popup roles — and implementing it correctly surfaces every gap in a team's understanding of interface states. I've started treating combobox implementation as a proxy for product quality. If a codebase has a correct combobox, I trust it. If it has <div onClick> with a fake dropdown, I know what I'm in for.
The ARIA 1.2 Pattern Is Specific on Purpose
The ARIA Authoring Practices Guide combobox pattern specifies roles, states, and keyboard behavior with precision that feels excessive until you try to implement it without reading it. The core structure:
<div role="combobox" aria-expanded={isOpen} aria-haspopup="listbox" aria-owns="listbox-id">
<input
type="text"
aria-autocomplete="list"
aria-controls="listbox-id"
aria-activedescendant={activeOption ? `option-${activeOption}` : undefined}
/>
</div>
<ul
id="listbox-id"
role="listbox"
aria-label="Suggestions"
>
{options.map((opt) => (
<li
key={opt.id}
id={`option-${opt.id}`}
role="option"
aria-selected={opt.id === selectedId}
>
{opt.label}
</li>
))}
</ul>
Three attributes on that input do distinct work. aria-autocomplete="list" tells screen readers the input filters a visible list. aria-controls points to the popup — it's the machine-readable equivalent of the visual line between input and dropdown. aria-activedescendant is what makes keyboard navigation work without moving DOM focus: you keep focus on the input while announcing which option is "active" by ID reference.
That last one breaks naive implementations. Moving DOM focus to each list item on arrow key press feels correct — the keyboard moves, the focus ring moves. But it breaks on mobile screen readers that expect the input to hold focus throughout, and it breaks backspace behavior (which should edit the input, not remove a focused list item). aria-activedescendant is the right pattern because it keeps the focus model consistent.
What Keyboard Behavior Forces You to Decide
The keyboard contract for a combobox per ARIA 1.2:
- ArrowDown: Opens popup if closed. Moves active option down.
- ArrowUp: Moves active option up. If at top, wraps or returns to input.
- Home / End: Moves to first / last option.
- Enter: Selects active option. Closes popup.
- Escape: Closes popup. Optionally clears the input or restores the previous value.
- Tab: Closes popup. Moves focus to next element. Does not select.
Each of these forces a product decision. What does Escape do to the typed text? Does ArrowDown wrap from last to first, or stop? What happens if the user types, sees a filtered list, then hits Escape — is their typed text the new value, or does it revert to the last committed selection?
There's no universal answer, but the act of implementing forces you to pick one. Teams that skip this step ship comboboxes where Escape does nothing, or Enter both submits the form and selects an option, or arrow keys scroll the page while also trying to navigate the list (because event.preventDefault() wasn't called).
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
switch (e.key) {
case "ArrowDown":
e.preventDefault(); // stop page scroll
setActiveIndex((i) => (i === null ? 0 : Math.min(i + 1, options.length - 1)));
if (!isOpen) setIsOpen(true);
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((i) => (i === null || i === 0 ? null : i - 1));
break;
case "Enter":
e.preventDefault(); // stop form submission
if (activeIndex !== null) {
selectOption(options[activeIndex]);
}
break;
case "Escape":
setIsOpen(false);
setActiveIndex(null);
// product decision: do we clear input or restore last committed value?
inputRef.current?.select();
break;
case "Tab":
setIsOpen(false);
setActiveIndex(null);
break;
}
}
e.preventDefault() on ArrowDown and Enter is not optional. Without it, arrow keys scroll the page and Enter submits any parent form. These are the bugs users report as "the dropdown is broken" without being able to articulate why.
Live Region Announcements
Visual users see the option count update as they type. Screen reader users hear nothing unless you explicitly announce it. The pattern is a visually hidden live region that updates with a summary of available options:
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{isOpen
? `${options.length} option${options.length !== 1 ? "s" : ""} available`
: ""}
</div>
aria-live="polite" waits for the user to finish their current action before announcing. aria-atomic="true" means the screen reader reads the full string rather than diffing changes. The .sr-only class (or equivalent) keeps it off-screen visually while remaining in the accessibility tree.
The timing here is subtle. If you update the live region on every keystroke, VoiceOver on iOS may interrupt the announcement of what the user just typed. Debouncing the live region update by 400–500ms prevents this. The debounce is for the announcement, not for the filtering — the list filters immediately, the count is announced after the user pauses.
Mobile vs. Desktop: Where the Pattern Diverges
On desktop, aria-activedescendant with keyboard navigation is the correct pattern. On iOS with VoiceOver, the behavior of comboboxes is different — native <select> elements get a native picker that screen readers navigate correctly. Custom comboboxes on mobile often perform worse than a native select even when technically correct per ARIA 1.2.
This doesn't mean "use native select on mobile." It means: test with VoiceOver on iOS and TalkBack on Android, not just NVDA on Windows. A combobox that passes automated accessibility checks but hasn't been tested with a real screen reader is an accessibility claim, not an accessible component.
The most common mobile failure I see: the popup opens, keyboard navigation works, but pinch-to-zoom is disabled (via meta viewport tag) and the input's font-size is below 16px, triggering iOS auto-zoom on focus. The combobox opens and the page jumps. Setting font-size: 16px or higher on the input prevents the jump without disabling zoom globally.
Listbox vs. Grid
When your combobox options are simple strings, role="listbox" with role="option" children is correct. When options have multiple columns — a name, a category badge, a keyboard shortcut — you want role="grid" with role="row" and role="gridcell" children. The keyboard contract changes: ArrowLeft/ArrowRight navigate between cells.
Most teams reach for listbox and call it done. The grid pattern matters when you're building search results with metadata, command palettes with shortcut hints, or multi-column option lists. Getting it wrong means screen readers announce a flat string concatenation of your column content rather than navigating each column separately.
What This Reveals About the Team
Building a correct combobox requires holding several things in mind simultaneously: the ARIA role hierarchy, keyboard event behavior, DOM focus state, live region timing, and mobile screen reader quirks. It requires product decisions that most designs leave implicit.
A codebase that has solved this has engineers who know that event.preventDefault() and aria-activedescendant are load-bearing. It has a test suite that includes keyboard navigation tests (not just click tests). It has product decisions written down somewhere — what Escape does, whether options wrap, what empty state looks like — because the implementation forced the question.
A codebase that skipped this has a <div> that opens a <div> when clicked. It probably works for mouse users. The keyboard and screen reader experience is an afterthought, which means every other edge case in the product is also an afterthought. The combobox is a tell.
Reach for Radix UI's Combobox or Headless UI's Combobox if you need a production implementation today. But read the source. The ARIA attributes, the keyboard handlers, the focus management — all of it is there, and studying it is faster than debugging your own from scratch.