Building an Accessible React Combobox in React (Select/Autocomplete) the Right Way

Build a production-ready, accessible React combobox/select with correct ARIA, keyboard support, async data, virtualization, and testing tips.

ASOasis
9 min read
Building an Accessible React Combobox in React (Select/Autocomplete) the Right Way

Image used for representation purposes only.

Overview

A combobox is an editable input paired with a list of suggestions. Done right, it lets keyboard and screen‑reader users discover and select options as efficiently as mouse users. Done wrong, it becomes a trap of lost focus, silent updates, and broken forms.

This article shows how to build a production‑ready, accessible combobox (select/autocomplete) in React. We’ll cover when to use it, the required ARIA semantics, robust keyboard behavior, and a clean, type‑safe implementation you can drop into your app.

Combobox vs. select vs. datalist

Pick the right control before you start coding:

  • Native select: best when choices are finite and not filterable. It is the most accessible by default but not searchable.
  • input with listbox (combobox): best for filterable suggestions with text input. Users can type or pick from a list.
  • input with datalist: easy and native, but discoverability and screen‑reader support vary; lacks rich control over highlighting, async loading, and results messaging.

If users need to type freeform text and optionally choose a suggested value, choose a combobox.

Accessibility requirements at a glance

A well‑behaved combobox exposes correct names, roles, and states:

  • The text field has role=“combobox” and aria-autocomplete=“list” (or “both”).
  • It controls a popup list with role=“listbox” via aria-controls and aria-expanded.
  • The current option is communicated by aria-activedescendant on the input.
  • Each option has role=“option” and toggles aria-selected.
  • The input has a programmatic label via label + htmlFor, aria-label, or aria-labelledby.
  • Keyboard support: ArrowUp/Down move through options; Enter selects; Escape closes; Tab commits the current value; Home/End jump within the input or list; typing filters results.
  • A polite status message announces result counts and selection changes.
  • Focus remains on the input while navigating options (activedescendant pattern).

Component anatomy

  • Input (role=“combobox”): the focus anchor and type target.
  • Popup list (role=“listbox”): the suggestion surface, absolutely positioned.
  • Options (role=“option”): selectable rows.
  • Status region (role=“status” aria-live=“polite”): announces count and changes.
  • Hidden input (optional): to integrate with HTML forms.

A minimal, accessible React combobox (TypeScript)

The following component implements the ARIA 1.2 combobox pattern with the activedescendant approach. It handles keyboard and mouse interactions, announces updates, and integrates with forms.

import React, {useEffect, useId, useMemo, useRef, useState} from 'react';

type Option = { id: string; label: string; value?: string };

type ComboboxProps = {
  label: string;
  options: Option[];
  value: Option | null;
  onChange: (option: Option | null) => void;
  placeholder?: string;
  name?: string; // for form POST via hidden input
  disabled?: boolean;
  autoComplete?: 'list' | 'both' | 'none';
};

export function Combobox({
  label,
  options,
  value,
  onChange,
  placeholder,
  name,
  disabled,
  autoComplete = 'list',
}: ComboboxProps) {
  const inputId = useId();
  const listboxId = useId();
  const [open, setOpen] = useState(false);
  const [inputValue, setInputValue] = useState(value?.label ?? '');
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLUListElement>(null);

  // Filter options (case-insensitive, simple contains)
  const filtered = useMemo(() => {
    const q = inputValue.trim().toLowerCase();
    if (!q) return options;
    return options.filter(o => o.label.toLowerCase().includes(q));
  }, [inputValue, options]);

  const activeId = activeIndex != null ? `${listboxId}-opt-${activeIndex}` : undefined;

  useEffect(() => {
    // Keep input text in sync when parent value changes externally
    setInputValue(value?.label ?? '');
  }, [value]);

  function commitSelection(index: number | null) {
    const opt = index != null ? filtered[index] : null;
    onChange(opt ?? null);
    setInputValue(opt?.label ?? inputValue);
    setOpen(false);
  }

  function onInputChange(e: React.ChangeEvent<HTMLInputElement>) {
    setInputValue(e.target.value);
    setOpen(true);
    setActiveIndex(0);
  }

  function onInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
    if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
      setOpen(true);
    }

    const last = filtered.length - 1;
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!filtered.length) return;
        setActiveIndex(i => (i == null ? 0 : Math.min(last, i + 1)));
        break;
      case 'ArrowUp':
        e.preventDefault();
        if (!filtered.length) return;
        setActiveIndex(i => (i == null ? last : Math.max(0, i - 1)));
        break;
      case 'Home':
        if (e.ctrlKey || e.metaKey) return; // let browser handle jump to start of input
        if (open && filtered.length) {
          e.preventDefault();
          setActiveIndex(0);
        }
        break;
      case 'End':
        if (e.ctrlKey || e.metaKey) return; // keep input behavior
        if (open && filtered.length) {
          e.preventDefault();
          setActiveIndex(last);
        }
        break;
      case 'Enter':
        if (open) {
          e.preventDefault();
          commitSelection(activeIndex);
        }
        break;
      case 'Escape':
        if (open) {
          e.preventDefault();
          setOpen(false);
          setActiveIndex(null);
        } else {
          // clear on second escape
          if (inputValue) setInputValue('');
        }
        break;
      default:
        break;
    }
  }

  // Close when clicking outside
  useEffect(() => {
    function onDocMouseDown(ev: MouseEvent) {
      if (!inputRef.current || !listRef.current) return;
      const root = inputRef.current.parentElement!;
      if (!root.contains(ev.target as Node)) {
        setOpen(false);
        setActiveIndex(null);
      }
    }
    document.addEventListener('mousedown', onDocMouseDown);
    return () => document.removeEventListener('mousedown', onDocMouseDown);
  }, []);

  return (
    <div className="cbx">
      <label htmlFor={inputId} className="cbx__label">{label}</label>
      <div className="cbx__field">
        <input
          id={inputId}
          ref={inputRef}
          type="text"
          role="combobox"
          aria-autocomplete={autoComplete}
          aria-expanded={open}
          aria-controls={listboxId}
          aria-activedescendant={activeId}
          aria-haspopup="listbox"
          className="cbx__input"
          placeholder={placeholder}
          disabled={disabled}
          value={inputValue}
          onChange={onInputChange}
          onKeyDown={onInputKeyDown}
          onFocus={() => setOpen(true)}
        />
        {name && (
          <input type="hidden" name={name} value={value?.value ?? value?.label ?? ''} />
        )}
      </div>

      {open && filtered.length > 0 && (
        <ul
          id={listboxId}
          role="listbox"
          ref={listRef}
          className="cbx__list"
        >
          {filtered.map((opt, i) => {
            const id = `${listboxId}-opt-${i}`;
            const active = i === activeIndex;
            return (
              <li
                id={id}
                key={opt.id}
                role="option"
                aria-selected={active}
                className={`cbx__option${active ? ' is-active' : ''}`}
                // Prevent input from blurring before click selects
                onMouseDown={(e) => e.preventDefault()}
                onClick={() => commitSelection(i)}
              >
                {opt.label}
              </li>
            );
          })}
        </ul>
      )}

      {/* Screen-reader only status updates */}
      <div role="status" aria-live="polite" className="sr-only">
        {open
          ? `${filtered.length} ${filtered.length === 1 ? 'result' : 'results'} available.`
          : ''}
        {value ? ` Selected ${value.label}.` : ''}
      </div>
    </div>
  );
}

Basic styles (including a visually hidden utility):

.cbx { position: relative; max-width: 28rem; }
.cbx__field { position: relative; }
.cbx__input { width: 100%; padding: 0.5rem 0.75rem; }
.cbx__list {
  position: absolute; z-index: 1000; margin-top: 0.25rem;
  max-height: 16rem; overflow: auto; width: 100%;
  background: white; border: 1px solid #ccc; border-radius: 0.25rem;
}
.cbx__option { padding: 0.5rem 0.75rem; cursor: pointer; }
.cbx__option.is-active, .cbx__option[aria-selected="true"] { background: #0b5fff; color: white; }

/* Visually hidden, screen-reader only */
.sr-only { position: absolute !important; height: 1px; width: 1px; overflow: hidden;
  clip: rect(1px, 1px, 1px, 1px); white-space: nowrap; }

Why aria-activedescendant?

Keeping focus on the input preserves typing, caret movement, dictation, and composition (IME) behavior. aria-activedescendant points to the “active” option by id, so screen readers announce it as you ArrowUp/ArrowDown without moving focus out of the field. This is the recommended pattern for comboboxes with text inputs.

Keyboard behavior checklist

Support the following interactions:

  • Typing filters suggestions and opens the list.
  • ArrowDown/ArrowUp move the active option; the active option is announced.
  • Enter selects the active option and closes the list.
  • Escape closes the list; pressing it again clears text (optional).
  • Tab commits the current text/value and moves focus onward.
  • Home/End move to first/last option when the list is open (while preserving native input behavior with Ctrl/Cmd).
  • PageUp/PageDown can jump by a page worth of options (optional enhancement).

Additionally:

  • Ensure the active option is scrolled into view.
  • Prevent blur-before-click by calling e.preventDefault() on mousedown of options.

Form integration

If your combobox is part of an HTML form, post either the option.value or the freeform text. The example includes an optional hidden input name prop for this. On change, update that hidden field with the selected option’s canonical value.

Async data and debouncing

For remote data, debounce input changes and render a loading row. Announce loading state in the status region.

const [loading, setLoading] = useState(false);
const [remote, setRemote] = useState<Option[]>([]);

useEffect(() => {
  const q = inputValue.trim();
  if (!q) { setRemote([]); return; }
  const t = setTimeout(async () => {
    setLoading(true);
    const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
    const items = (await res.json()) as Option[];
    setRemote(items); setLoading(false); setOpen(true); setActiveIndex(0);
  }, 200);
  return () => clearTimeout(t);
}, [inputValue]);

Remember to update the status region, e.g., “Loading results…” and “X results available.”

Virtualization for large lists

For thousands of options, virtualize the list with a library like react-window. Keep roles and aria-selected. The approach is the same: compute an item’s id from its virtual index, and set aria-activedescendant accordingly. Always ensure the active item is scrolled into view within the virtualizer.

import { FixedSizeList as List } from 'react-window';

function VirtualList({ items, height = 320, itemSize = 36, onSelect, activeIndex, listboxId }: {
  items: Option[]; height?: number; itemSize?: number; onSelect: (i: number) => void; activeIndex: number | null; listboxId: string;
}) {
  return (
    <List height={height} itemCount={items.length} itemSize={itemSize} width="100%" role="listbox" id={listboxId}>
      {({ index, style }) => (
        <div
          style={style}
          id={`${listboxId}-opt-${index}`}
          role="option"
          aria-selected={activeIndex === index}
          className={`cbx__option${activeIndex === index ? ' is-active' : ''}`}
          onMouseDown={e => e.preventDefault()}
          onClick={() => onSelect(index)}
        >
          {items[index].label}
        </div>
      )}
    </List>
  );
}

Mobile and IME considerations

  • Don’t steal focus from the input; let users type, dictate, or compose (e.g., Japanese IME). Avoid intercepting composition events; only filter on change/input, not on keydown during composition.
  • Make touch targets large (at least 44×44 px) and keep the list above other UI using a high z-index or a portal.

Visual polish that aids accessibility

  • Provide a strong focus outline on the input.
  • Ensure color contrast for active and selected options (≥ 4.5:1 for text).
  • Use spacing and hover states as additional, non-color cues.
  • If you decorate options (icons, groups), still keep the label as readable text.

Common pitfalls (and fixes)

  • Putting role=“combobox” on a div instead of the input. The text field itself should have the role.
  • Moving DOM focus into options (roving tabindex) for a text-entry combobox. Prefer aria-activedescendant so caret editing works.
  • Forgetting aria-controls or mismatching ids, which breaks the input→list association.
  • Not preventing blur on option mousedown, causing lost clicks.
  • Setting aria-selected on multiple options for a single-select combobox.
  • Omitting a live region; users get a silent, empty popup.

Automated and manual testing

Automate what you can and verify with assistive tech:

  • Lint: eslint-plugin-jsx-a11y flags missing ARIA wiring.
  • Unit/integration: @testing-library/user-event for Arrow keys, Enter, Escape, and typeahead; assert aria-activedescendant and aria-selected changes.
  • Accessibility: jest-axe or axe-core in Cypress to catch roles/attributes and color contrast.
  • Screen readers:
    • macOS VoiceOver + Safari: verify announcements of “listbox” and option changes.
    • NVDA + Firefox: Arrow keys should announce active options; Enter selects.
    • JAWS + Chrome/Edge: confirm aria-activedescendant updates and status messages.

Manual checklist:

  • Label is announced when focusing the input.
  • Typing opens the list and narrows results.
  • ArrowUp/Down update the active option; it’s scrolled into view.
  • Enter selects and closes; Escape closes without selection.
  • Tab moves to the next control and preserves the committed value.
  • Mouse click selects even if the input would otherwise blur.

Extending the pattern

  • Multi-select: switch to a tokens + listbox pattern; each token has an accessible remove button, and the listbox becomes multi-select with aria-selected on multiple options.
  • Grouped options: use role=“group” with aria-labelledby inside the listbox. Ensure groups are keyboard-reachable and announced.
  • Clear button: include an accessible clear control with aria-label=“Clear selection” and keyboard activation.
  • Controlled vs. uncontrolled: expose value/onChange and optionally defaultValue for flexibility.

When to use a library

If you need advanced focus management, rich composition support, or complex async states, consider mature headless libraries. They ship robust ARIA and keyboard behavior out of the box and let you style freely. Even if you roll your own, consult authoritative patterns and test across assistive technologies before shipping.

Summary

An accessible React combobox keeps focus in the input, exposes listbox semantics, updates aria-activedescendant as users navigate, and honors expected keyboard and screen‑reader behavior. With a small set of ARIA attributes, thoughtful interaction design, and thorough testing, you can deliver an autocomplete that is fast, inclusive, and production‑ready.

Related Posts