Building an Accessible Combobox in React
A combobox is a text input paired with a list of suggestions, and it is the widget teams most often get 80% right. The markup usually looks correct; what breaks is the detail — the roles land on a wrapper instead of the input, the highlight moves without aria-activedescendant, Escape clears the field instead of closing the list, or the option list is announced once and never again. This guide implements the ARIA 1.2 pattern in React with those details spelled out.
It is the implementation companion to Accessible Search & Filtering in React.
WCAG Success Criteria Addressed:
2.1.1 Keyboard2.4.7 Focus Visible3.3.2 Labels or Instructions4.1.2 Name, Role, Value
Prerequisites
- React 18+ (
useIdis used for stable option ids across server and client rendering). - A visible
<label>for the input; a combobox without one fails3.3.2 Labels or Instructionsregardless of how good the rest is. - A clear decision about behaviour: does selecting an option submit, navigate, or just fill the field? The key map depends on the answer.
Roles Go on the Real Elements
The most common structural mistake is wrapping everything in a <div role="combobox">. In ARIA 1.2 the input is the combobox; the wrapper is nothing.
Implementation Guidelines:
role="combobox",aria-expanded,aria-controlsandaria-autocompleteall belong on the<input>.- The list is
role="listbox"with a stableid; each item isrole="option"with a uniqueid. - Options must be direct children of the listbox (or wrapped in
role="group"), oraria-required-childrenfails and some screen readers ignore the list. - Do not give options
tabindex. They are never focused; the highlight is virtual.
'use client';
import { useId, useRef, useState } from 'react';
export function Combobox({ label, options, onSelect }: ComboboxProps) {
const baseId = useId();
const listId = `${baseId}-list`;
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const [value, setValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const activeId = active >= 0 ? `${baseId}-opt-${active}` : undefined;
return (
<div className="combobox">
<label htmlFor={`${baseId}-input`}>{label}</label>
<input
id={`${baseId}-input`}
ref={inputRef}
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeId}
value={value}
onChange={(event) => { setValue(event.target.value); setOpen(true); setActive(-1); }}
/>
<ul id={listId} role="listbox" hidden={!open}>
{options.map((option, index) => (
<li
key={option.id}
id={`${baseId}-opt-${index}`}
role="option"
aria-selected={index === active}
onMouseDown={(event) => { event.preventDefault(); onSelect(option); }}
>
{option.label}
</li>
))}
</ul>
</div>
);
}
onMouseDown with preventDefault rather than onClick is deliberate: clicking an option would otherwise blur the input first, closing the list before the click lands.
The Key Map, in Full
Half-implemented keyboard support is what makes a combobox feel broken. The list below is the minimum for the ARIA 1.2 pattern; each entry takes two lines of code and its absence is immediately noticeable.
Implementation Guidelines:
- Call
preventDefault()for the arrow keys,HomeandEndso the caret does not jump inside the text field while you are moving the highlight. - Keep
Enterfrom submitting the surrounding form while the list is open, but let it submit when the list is closed. - On
Escape, restore the value the user typed rather than the last highlighted option — that is what "cancel" means here. - Never accept the highlighted option on
Tab. Silent acceptance rewrites the field as the user leaves, which is the single most disliked combobox behaviour.
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setOpen(true);
setActive((index) => (index + 1) % options.length);
break;
case 'ArrowUp':
event.preventDefault();
setActive((index) => (index <= 0 ? options.length - 1 : index - 1));
break;
case 'Enter':
if (open && active >= 0) { event.preventDefault(); onSelect(options[active]); }
break;
case 'Escape':
// A11y rationale: cancel restores what the user typed — it does not commit
// the highlight, and it does not wipe their input on the first press.
if (open) { setOpen(false); setActive(-1); } else { setValue(''); }
break;
default:
break;
}
}
Making the Highlight Visible and Scrolled Into View
Because DOM focus never moves, the browser does nothing for you: no focus ring, no automatic scrolling. Both are your responsibility.
Implementation Guidelines:
- Style
[aria-selected="true"]with a background and a border or outline, so the highlight survives forced-colors mode where background colours are overridden. - Scroll the active option into view manually with
scrollIntoView({ block: 'nearest' })when the index changes. - Keep the input's own focus ring visible at all times — it is still the focused element, and hiding it strands sighted keyboard users.
- Do not use
:hoverstyling as the highlight. Pointer position and keyboard highlight are different things and showing both at once is confusing.
useEffect(() => {
if (active < 0) return;
const node = document.getElementById(`${baseId}-opt-${active}`);
// 2.4.7 Focus Visible — the virtual highlight must be scrolled into view the
// way the browser would scroll real focus.
node?.scrollIntoView({ block: 'nearest' });
}, [active, baseId]);
Async Options and Announcing Them
Fetching suggestions introduces a gap between typing and results, and the accessibility question is what happens in that gap.
Implementation Guidelines:
- Keep
aria-expanded="false"until there is something to show. An expanded, empty listbox announces a list with no items. - Announce the number of available options through a polite status region after results settle — debounced, so intermediate counts stay silent.
- Reset the active index to
-1whenever the option set changes, oraria-activedescendantwill point at an id that no longer exists. - Handle the empty case explicitly: either close the list or render a single non-selectable "No matches" message with
role="status"rather than an empty listbox.
<p role="status" aria-live="polite" className="visually-hidden">
{open && options.length > 0 ? `${options.length} suggestions available` : ''}
</p>
How to Verify
- Key-by-key pass. Walk the whole key map. Every row above should behave exactly as described, including wrapping and the two-stage
Escape. - Screen reader pass. With NVDA or VoiceOver, arrow through the options and confirm each is announced once, with its position ("2 of 12"), and that the input keeps reporting "combobox, expanded".
- Stale-id check. Type to filter until the list shrinks, then inspect
aria-activedescendant. It must reference an option that exists. - Automated scan. axe's
aria-required-children,aria-valid-attr-valueandlabelrules catch the structural wiring — see Component Testing with jest-axe. - Mouse-and-keyboard mix. Hover one option while arrowing to another. Exactly one should read as selected; if both look active, the hover style is masquerading as the highlight.
Common Accessibility Mistakes
role="combobox"on a wrapper<div>. The input is the combobox in ARIA 1.2; a wrapper role means the state is reported on the wrong element.- Moving DOM focus into the option list. Now the input has lost focus, typing stops working, and the pattern falls apart. Use
aria-activedescendant. - Accepting the highlight on
Tab. Rewrites the field as the user leaves. Tab closes and commits nothing. - Leaving
aria-activedescendantpointing at a removed option. The screen reader announces nothing, and the widget appears frozen. - An expanded listbox with zero options. Announces "listbox, 0 items". Close it, or show a status message instead.
- No visible highlight in forced-colors mode. Background-only highlights disappear; add an outline or border.
Conclusion
The combobox pattern is unforgiving because every part of it is observable: a missing aria-expanded is audible, a stale aria-activedescendant freezes the widget, and an unimplemented Home key is noticed the first time someone tries it. Put the roles on the real elements, keep DOM focus in the input, implement the entire key map, and treat the highlight as something you must both style and scroll. Done properly it is perhaps 150 lines — and it will outlive three redesigns, which is more than can be said for most dependency choices.
Frequently Asked Questions
Should I use aria-activedescendant or roving tabindex here?aria-activedescendant, because focus must stay in the text input so the user can keep typing. Roving tabindex moves real DOM focus, which is right for toolbars and tab lists but would break typing in a combobox. The comparison is covered in roving tabindex for navigation menus.
Is aria-owns needed if the list is not a DOM sibling?
If the listbox is rendered in a portal, aria-controls still identifies it, and most screen readers cope. Where the relationship genuinely needs to be expressed in the accessibility tree, aria-owns on the input can help — but rendering the list adjacent to the input in the DOM avoids the question entirely and is usually achievable with CSS positioning.
What about aria-autocomplete="both" or "inline"?
Use "list" when the list filters as the user types and the field is not modified. Use "both" when you also complete the text inline with a selected range. Inline completion is disorienting for many users, so if you ship it, make sure Escape restores what they actually typed.
Should selecting an option submit the form?
Only if the product genuinely is "search as you pick" — and then say so in the label or hint text. Otherwise, selection fills the field and the user submits deliberately. Surprise navigation on selection is a 3.2.2 On Input problem.
Does the input need aria-describedby for the keyboard hints?
It can help for unusual behaviour, but keep hints short; they are announced on every focus. A better default is to make the interaction conventional enough that no hint is needed, and reserve aria-describedby for genuinely non-obvious constraints such as a minimum query length.
Related guides
- Accessible Search & Filtering in React — the parent guide covering the whole search interaction.
- Announcing Search Result Counts to Screen Readers — the polite status region this widget relies on.
- Building Accessible Dropdowns Without External UI Kits — the select-style variant of the same virtual-focus model.
- Roving Tabindex for Navigation Menus — when real focus movement is the better choice.