react nextjs accessibility patterns

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 Keyboard
  • 2.4.7 Focus Visible
  • 3.3.2 Labels or Instructions
  • 4.1.2 Name, Role, Value
Which element carries each combobox attribute A labelled text input sits above a listbox of three options. Callouts show that the input itself carries role combobox, aria-expanded, aria-controls pointing at the list, aria-autocomplete list, and aria-activedescendant pointing at the highlighted option. The list carries role listbox and an id. Each option carries role option, a unique id, and aria-selected. A note states that DOM focus never leaves the input. <label for="city"> City Col On the input role="combobox" · aria-expanded="true" aria-controls="city-list" aria-autocomplete="list" aria-activedescendant="opt-2" <ul id="city-list" role="listbox"> Cologne Colombo id="opt-2" Colorado Springs On each option role="option" · unique id aria-selected on the highlighted one no tabindex — never focused directly DOM focus stays in the input the whole time — the highlight is virtual, reported by aria-activedescendant.

Prerequisites

  • React 18+ (useId is used for stable option ids across server and client rendering).
  • A visible <label> for the input; a combobox without one fails 3.3.2 Labels or Instructions regardless 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-controls and aria-autocomplete all belong on the <input>.
  • The list is role="listbox" with a stable id; each item is role="option" with a unique id.
  • Options must be direct children of the listbox (or wrapped in role="group"), or aria-required-children fails 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.

The combobox key map A table of eight keys and their required behaviour. Arrow down opens the list or moves the highlight down, wrapping at the end. Arrow up moves up, wrapping at the start. Home and End jump to the first and last option when the list is open. Enter selects the highlighted option and closes the list. Escape closes the list and restores what the user typed, and a second Escape clears the field. Tab closes the list and moves on, accepting nothing. Typing filters the list and resets the highlight. Alt plus arrow down opens the list without moving the highlight. Key Required behaviour ArrowDown Open the list, or move the highlight down and wrap at the end ArrowUp Move the highlight up and wrap to the last option Home / End Jump to the first or last option while the list is open Enter Select the highlighted option, close the list, keep focus in the input Escape Close the list and restore the typed text; a second press clears the field Tab Close the list and move on — never silently accept the highlight Alt+ArrowDown Open the list without moving the highlight

Implementation Guidelines:

  • Call preventDefault() for the arrow keys, Home and End so the caret does not jump inside the text field while you are moving the highlight.
  • Keep Enter from 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 :hover styling 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 -1 whenever the option set changes, or aria-activedescendant will 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>
State of the combobox while suggestions are being fetched Three moments during an async fetch. While the request is in flight the list stays collapsed and the active index is minus one, so nothing stale is referenced. When results arrive the list expands, the active index stays at minus one until the user presses an arrow key, and a polite status announces how many suggestions are available. When results come back empty the list stays collapsed and a status message says no matches, instead of expanding an empty listbox. Request in flight aria-expanded="false" activedescendant cleared status region silent nothing stale to point at Results arrive aria-expanded="true" active index reset to -1 "12 suggestions available" announced politely, once No matches list stays collapsed "No matches for Colx" never an empty listbox announcing "0 items" Resetting the active index on every option-set change is what keeps aria-activedescendant from referencing an option that has just been filtered away — the failure that makes a combobox appear frozen.

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-value and label rules 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-activedescendant pointing 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.