All posts
20 Sep 2026

Beyond the Basics: Building a Fully Accessible Combobox and Autocomplete in React

A deep dive into building an accessible React combobox and autocomplete from scratch using robust keyboard navigation, ARIA attributes, and screen reader announcements.

Beyond the Basics: Building a Fully Accessible Combobox and Autocomplete in React

Autocomplete and combobox components are staples of modern web applications. Whether you are building a country selector, a global search bar, or an ‘@’ mention input, the user experience hinges on responsiveness and predictability. However, building these components in React often leads to accessibility regressions.

Screen readers get confused, keyboard traps catch users off guard, and focus management breaks down when filtering lists dynamically.

In this guide, we will build a production-ready, fully accessible Combobox component in React using TypeScript. We won’t rely on heavy UI libraries; instead, we will leverage native HTML, the WAI-ARIA 1.2 Combobox pattern, and precise event handling.


Understanding the ARIA 1.2 Combobox Pattern

The WAI-ARIA specification for comboboxes has historically been notoriously difficult to implement. Fortunately, ARIA 1.2 simplified the mental model significantly.

At its core, a combobox is an input pattern that controls a popup (usually a listbox) to help the user set the value of the input.

The semantic relationship relies on three primary elements:

  1. The Combobox Input (role="combobox"): The text field receiving user input.
  2. The Popup (role="listbox"): The container holding the selectable options.
  3. The Options (role="option"): The individual items inside the listbox.

Key ARIA Attributes to Remember

  • aria-expanded: Tells assistive technologies whether the popup list is currently visible (true or false).
  • aria-haspopup: Set to "listbox" on the input element.
  • aria-controls: Links the input directly to the DOM id of the listbox.
  • aria-autocomplete: Indicates how the suggestion behavior works ("list", "inline", or "both").
  • aria-activedescendant: Identifies the currently focused option within the listbox without moving the physical DOM focus away from the input.

Focus Management: aria-activedescendant vs. Roving Tabindex

When building list navigation, you have two primary strategies for managing keyboard focus:

  1. Roving Tabindex: Shifting tabindex="0" dynamically to the currently focused DOM node while setting others to tabindex="-1".
  2. aria-activedescendant: Keeping the DOM focus permanently on the <input> element while using an ID reference to signal to screen readers which option is visually highlighted.

Why Choose aria-activedescendant for Comboboxes?

For an autocomplete combobox, keeping focus on the input is superior. If focus moves to the listbox items, the user loses their cursor position in the text input, breaking typing flow.

By using aria-activedescendant, the user can type, use the arrow keys to highlight suggestions, and read the updated selection instantly—all while the cursor remains firmly inside the input.


Building the Component: Step-by-Step

Let’s construct our TypeScript-powered component. We will create a flexible Autocomplete component that accepts a list of items and an on-select callback.

1. Types and Interfaces

tsx
import React, { useState, useRef, useId, KeyboardEvent, ChangeEvent } from 'react';

export interface Option {
  id: string;
  label: string;
}

interface AutocompleteProps {
  options: Option[];
  value: string;
  onChange: (value: string) => void;
  onSelect: (option: Option) => void;
  placeholder?: string;
}

2. Component Structure and State

We need state for tracking the open/closed status of the dropdown, the highlighted index for keyboard navigation, and a live-region announcer for screen reader feedback.

export const Autocomplete: React.FC<AutocompleteProps> = ({
  options,
  value,
  onChange,
  onSelect,
  placeholder = 'Search...',
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
  const [announcement, setAnnouncement] = useState<string>('');

  const inputRef = useRef<HTMLInputElement>(null);
  const listboxId = useId();

  // Filter options based on input value
  const filteredOptions = options.filter(option =>
    option.label.toLowerCase().includes(value.toLowerCase())
  );

  // ... handlers go here

  return (
    <div className="relative w-full max-w-sm">
      {/* Screen Reader Live Region */}
      <div className="sr-only" aria-live="polite" aria-atomic="true">
        {announcement}
      </div>
      
      {/* Component markup continues below */}
    </div>
  );
};

3. Implementing Keyboard Navigation

Handling keyboard events is where accessibility is won or lost. We need to support:

  • ArrowDown / ArrowUp: Cycle through suggestions.
  • Enter: Select the currently highlighted option.
  • Escape: Close the listbox.
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      if (!isOpen) {
        setIsOpen(true);
      }
      setHighlightedIndex(prev => {
        const nextIndex = prev < filteredOptions.length - 1 ? prev + 1 : 0;
        announceSelection(filteredOptions[nextIndex]);
        return nextIndex;
      });
      break;

    case 'ArrowUp':
      e.preventDefault();
      if (!isOpen) {
        setIsOpen(true);
      }
      setHighlightedIndex(prev => {
        const nextIndex = prev > 0 ? prev - 1 : filteredOptions.length - 1;
        announceSelection(filteredOptions[nextIndex]);
        return nextIndex;
      });
      break;

    case 'Enter':
      e.preventDefault();
      if (isOpen && highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
        selectOption(filteredOptions[highlightedIndex]);
      }
      break;

    case 'Escape':
      setIsOpen(false);
      setHighlightedIndex(-1);
      break;

    default:
      break;
  }
};

const announceSelection = (option: Option) => {
  if (option) {
    setAnnouncement(`${option.label}, suggestion ${highlightedIndex + 1} of ${filteredOptions.length}`);
  }
};

const selectOption = (option: Option) => {
  onSelect(option);
  onChange(option.label);
  setIsOpen(false);
  setHighlightedIndex(-1);
  inputRef.current?.focus();
};

4. Assembling the JSX and ARIA Bindings

Now, let’s connect our state and handlers to the DOM elements using proper ARIA attributes.

  return (
    <div className="relative w-full max-w-sm">
      <div className="sr-only" aria-live="polite">
        {announcement}
      </div>

      <div className="flex flex-col">
        <label htmlFor={`${listboxId}-input`} className="text-sm font-medium mb-1">
          Select an option
        </label>
        <input
          ref={inputRef}
          id={`${listboxId}-input`}
          type="text"
          role="combobox"
          aria-expanded={isOpen}
          aria-haspopup="listbox"
          aria-controls={listboxId}
          aria-autocomplete="list"
          aria-activedescendant={
            highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined
          }
          value={value}
          onChange={e => {
            onChange(e.target.value);
            setIsOpen(true);
            setHighlightedIndex(-1);
          }}
          onFocus={() => setIsOpen(true)}
          onKeyDown={handleKeyDown}
          placeholder={placeholder}
          className="px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </div>

      {isOpen && filteredOptions.length > 0 && (
        <ul
          id={listboxId}
          role="listbox"
          className="absolute z-10 w-full mt-1 bg-white border rounded-md shadow-lg max-h-60 overflow-auto"
        >
          {filteredOptions.map((option, index) => {
            const isHighlighted = index === highlightedIndex;
            return (
              <li
                key={option.id}
                id={`${listboxId}-option-${index}`}
                role="option"
                aria-selected={isHighlighted}
                onClick={() => selectOption(option)}
                onMouseEnter={() => setHighlightedIndex(index)}
                className={`px-3 py-2 cursor-pointer ${
                  isHighlighted ? 'bg-blue-100 text-blue-900' : 'text-gray-900'
                }`}
              >
                {option.label}
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
};

Handling Edge Cases and Polish

An accessible component must handle edge cases gracefully. Here are three critical enhancements to keep in mind:

1. Click-Outside to Close

If a user clicks anywhere outside the input or dropdown, the listbox should collapse.

import { useEffect } from 'react';

// Inside your component:
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  const handleClickOutside = (event: MouseEvent) => {
    if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
      setIsOpen(false);
    }
  };

  document.addEventListener('mousedown', handleClickOutside);
  return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

2. Screen Reader Live Announcements

While aria-activedescendant informs screen readers of focus shifts in modern browsers, inconsistent screen reader support across platforms (like NVDA or VoiceOver combined with various browsers) makes a visually hidden aria-live region a bulletproof fallback for announcing option counts and changes.

3. Mouse and Keyboard Interoperability

Notice how our implementation handles onMouseEnter alongside keyboard navigation. When a user hovers over an option with the mouse, highlightedIndex updates so that if they switch immediately to keyboard navigation (e.g., pressing ArrowDown), the selection picks up right where the mouse left off.


Conclusion

Building an accessible combobox from scratch requires careful orchestration of focus states, ARIA attributes, and keyboard listeners. By utilizing the WAI-ARIA 1.2 pattern, keeping focus on the input with aria-activedescendant, and carefully managing state transitions, you can deliver a buttery-smooth autocomplete experience that works seamlessly for everyone—whether they use a mouse, a keyboard, or a screen reader.

Checklist for Your Next Custom Combobox:

  • Input has role="combobox", aria-expanded, aria-haspopup, and aria-controls.
  • Listbox has role="listbox" and a matching unique id.
  • Options have role="option" and aria-selected.
  • aria-activedescendant points to the active option ID while focus stays on the input.
  • Comprehensive keyboard support (ArrowDown, ArrowUp, Enter, Escape).
  • Click-outside listener to close the dropdown securely.

More posts