All posts
19 Oct 2026

Building an Accessible Combobox in React: ARIA Combobox Patterns and Keyboard Navigation

A practical technical guide on building a robust, accessible autocomplete combobox in React using state machines, ARIA attributes, and full keyboard navigation.

Building an Accessible Combobox in React: ARIA Combobox Patterns and Keyboard Navigation

The combobox is one of the most notoriously tricky user interface components to build correctly. It sits at the intersection of a text input and a listbox, requiring simultaneous management of text entry, asynchronous filtering, popover positioning, and complex keyboard navigation.

When built poorly, a combobox is a nightmare for screen reader users and keyboard-only power users. When built correctly—adhering to the WAI-ARIA Authoring Practices Guide (APG)—it provides a seamless, native-feeling experience for everyone.

In this guide, we will build a production-ready, fully accessible autocomplete combobox in React and TypeScript. We’ll break down the state machine, wire up the critical ARIA attributes, and implement robust arrow-key navigation.


Understanding the ARIA Combobox Anatomy

Before writing code, we must understand the contract between our component and assistive technologies. An accessible combobox relies on a relationship between three primary elements:

  1. The Combobox Input: The text field where the user types. It carries role="combobox".
  2. The Listbox Popup: The container holding the selectable options. It carries role="listbox".
  3. The Options: Individual items inside the listbox, carrying role="option".

The Critical ARIA Attributes

  • aria-expanded="true|false": Informs the screen reader whether the popup listbox is currently open.
  • aria-haspopup="listbox": Declares that this input triggers a listbox popup.
  • aria-controls="element-id": Points the input to the ID of the listbox popup.
  • aria-autocomplete="list": Tells the assistive tech that typing filters a list of choices.
  • aria-activedescendant="option-id": Points to the ID of the currently focused option in the listbox while focus remains on the input.

Designing the State Machine

A predictable combobox relies on clear state management. We need to track:

  • query: The current text value inside the input.
  • isOpen: Whether the listbox is visible.
  • activeIndex: The index of the currently highlighted option in the listbox (-1 when no option is highlighted).
  • selectedItem: The committed selection.

Let’s define our TypeScript types and state structure:

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

interface ComboboxProps<T> {
  items: T[];
  itemToString: (item: T) => string;
  onSelect: (item: T) => void;
  placeholder?: string;
}

Building the React Component

Below is the complete implementation of our accessible combobox. We’ll use React hooks to manage focus, keyboard events, and ARIA bindings.

export function Combobox<T>({ items, itemToString, onSelect, placeholder }: ComboboxProps<T>) {
  const [query, setQuery] = useState('');
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState<number>(-1);

  const inputRef = useRef<HTMLInputElement>(null);
  const listboxRef = useRef<HTMLUListElement>(null);

  const comboboxId = useId();
  const listboxId = `${comboboxId}-listbox`;
  const inputId = `${comboboxId}-input`;

  // Filter items based on user input
  const filteredItems = useMemo(() => {
    if (!query) return items;
    return items.filter(item => 
      itemToString(item).toLowerCase().includes(query.toLowerCase())
    );
  }, [items, query, itemToString]);

  // Reset active index when filtered items change
  useEffect(() => {
    setActiveIndex(-1);
  }, [query]);

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value);
    setIsOpen(true);
  };

  const handleOptionClick = (item: T) => {
    setQuery(itemToString(item));
    onSelect(item);
    setIsOpen(false);
    setActiveIndex(-1);
    inputRef.current?.focus();
  };

  // ... Keyboard navigation logic goes here

  return (
    <div className="combobox-wrapper">
      <label htmlFor={inputId} className="combobox-label">
        Search Options
      </label>
      <div className="combobox-input-container">
        <input
          ref={inputRef}
          id={inputId}
          role="combobox"
          aria-expanded={isOpen}
          aria-haspopup="listbox"
          aria-controls={listboxId}
          aria-autocomplete="list"
          aria-activedescendant={
            activeIndex >= 0 && isOpen ? `${comboboxId}-option-${activeIndex}` : undefined
          }
          value={query}
          onChange={handleInputChange}
          placeholder={placeholder}
          // Keyboard handlers added in next section
        />
      </div>

      {isOpen && filteredItems.length > 0 && (
        <ul
          ref={listboxRef}
          id={listboxId}
          role="listbox"
          className="combobox-listbox"
        >
          {filteredItems.map((item, index) => {
            const isSelected = activeIndex === index;
            const optionId = `${comboboxId}-option-${index}`;

            return (
              <li
                key={optionId}
                id={optionId}
                role="option"
                aria-selected={isSelected}
                className={`combobox-option ${isSelected ? 'active' : ''}`}
                onClick={() => handleOptionClick(item)}
                onMouseEnter={() => setActiveIndex(index)}
              >
                {itemToString(item)}
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}

Implementing Robust Keyboard Navigation

Keyboard navigation is where custom comboboxes frequently fail. Screen reader and power users rely entirely on specific keystrokes:

  • ArrowDown: Opens the listbox if closed, or moves the active highlight down to the next option.
  • ArrowUp: Moves the active highlight up to the previous option.
  • Enter: Commits the currently highlighted option, or closes the listbox if none is highlighted.
  • Escape: Closes the listbox and clears the input (or resets to previous state).

Let’s add the onKeyDown handler to our input element:

const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      if (!isOpen) {
        setIsOpen(true);
      } else {
        setActiveIndex(prev => 
          prev < filteredItems.length - 1 ? prev + 1 : 0
        );
      }
      break;

    case 'ArrowUp':
      e.preventDefault();
      if (!isOpen) {
        setIsOpen(true);
      } else {
        setActiveIndex(prev => (prev > 0 ? prev - 1 : filteredItems.length - 1));
      }
      break;

    case 'Enter':
      e.preventDefault();
      if (isOpen && activeIndex >= 0 && filteredItems[activeIndex]) {
        handleOptionClick(filteredItems[activeIndex]);
      } else {
        setIsOpen(false);
      }
      break;

    case 'Escape':
      e.preventDefault();
      setIsOpen(false);
      setActiveIndex(-1);
      break;

    case 'Tab':
      // Close listbox on tab out to allow natural focus flow
      setIsOpen(false);
      break;

    default:
      break;
  }
};

Why aria-activedescendant Matters

Notice that as the user presses ArrowDown and ArrowUp, focus never leaves the input. Instead, we increment and decrement activeIndex and update aria-activedescendant="combobox-option-3".

Screen readers intercept this attribute change and announce the newly highlighted option without shifting browser focus away from the typing context. This is the gold standard for autocomplete behavior.


Handling Click-Outside and Blur Events

A complete combobox must close its dropdown when the user clicks outside or tabs away from the component entirely.

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

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

Conclusion

Building an accessible combobox in React requires careful coordination between state, focus management, and WAI-ARIA specifications. By utilizing aria-activedescendant for keyboard traversal and maintaining correct popup states with aria-expanded and aria-controls, you ensure that your component is fully usable by screen reader users and keyboard navigators alike.

Key Takeaways Checklist

  • Input has role="combobox", aria-expanded, and aria-controls.
  • Popup container has role="listbox" and a unique ID matching the input’s aria-controls.
  • Options have role="option" and aria-selected states.
  • Keyboard focus stays on the input while aria-activedescendant tracks the highlighted option.
  • Arrow keys, Enter, Escape, and Tab are explicitly handled to manage open/closed states and item selection.

More posts