All posts
13 Oct 2026

Building an Accessible Combobox in React: The Complete Guide to Autocomplete UI

Learn how to build a fully accessible combobox and autocomplete widget in React from scratch, complete with robust keyboard navigation, ARIA attributes, and screen reader support.

Building an Accessible Combobox in React: The Complete Guide to Autocomplete UI

Implementing a combobox—often referred to as an autocomplete or typeahead widget—is one of the most deceptively complex UI challenges in front-end development. What looks like a simple text input with a dropdown list requires careful orchestration of state, focus management, screen reader announcements, and intricate keyboard interactions.

When accessibility (a11y) is added to the mix, the complexity scales up. A poorly constructed combobox can trap screen reader users, fail keyboard traps, or completely ignore dynamic updates to filtered results.

In this comprehensive guide, we will build a robust, accessible combobox in React using TypeScript. We will dive deep into the WAI-ARIA 1.2 Combobox specification, contrast architectural patterns like aria-activedescendant versus roving tabindex, and implement seamless keyboard navigation.


Understanding the Anatomy of a Combobox

A WAI-ARIA combobox is a composite widget consisting of two primary parts:

  1. An input element (typically a text input) that controls the widget.
  2. A popup element (a listbox, grid, or tree) that displays suggestions based on the input value.

Key ARIA Attributes Required

  • role="combobox": Placed on the input (or its wrapper, depending on the pattern), signaling that the element controls a popup.
  • aria-expanded: A boolean (true or false) indicating whether the popup is currently visible.
  • aria-haspopup: Set to "listbox" to inform assistive technologies that a list of choices will appear.
  • aria-controls: Links the input directly to the ID of the popup listbox.
  • aria-autocomplete: Typically set to "list" or "both" to indicate how the autocomplete behavior functions.

Architectural Choice: aria-activedescendant vs. Roving Tabindex

When implementing keyboard navigation within the popup listbox, you have two primary architectural patterns to choose from:

  1. aria-activedescendant (Recommended for Comboboxes): Focus remains on the text input at all times. As the user presses the Arrow Down or Arrow Up keys, an ID reference is passed to the input’s aria-activedescendant attribute. Screen readers announce the newly active option automatically without shifting browser focus away from the input.

  2. Roving Tabindex: Focus actually moves from the input element directly into the DOM nodes of the list items using tabindex="-1" and tabindex="0".

For comboboxes, aria-activedescendant is vastly superior. Moving focus away from a text input while a user is typing breaks the writing flow and often causes erratic behavior across different screen reader and browser combinations.


Building the Component (TypeScript & React)

Let us construct a production-ready, accessible autocomplete component. We will manage state for the input value, open/closed status, and the currently highlighted item index.

1. Types and Interfaces

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

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

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

2. Component Implementation

export const AccessibleCombobox: React.FC<AccessibleComboboxProps> = ({
  options,
  onSelect,
  placeholder = 'Search...',
}) => {
  const [query, setQuery] = useState('');
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState<number | null>(null);

  const comboboxId = useId();
  const listboxId = `${comboboxId}-listbox`;
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLUListElement>(null);

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

  // Close popup when clicking outside
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (
        inputRef.current &&
        !inputRef.current.contains(event.target as Node) &&
        listRef.current &&
        !listRef.current.contains(event.target as Node)
      ) {
        setIsOpen(false);
      }
    };
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

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

  const handleOptionClick = (option: Option) => {
    setQuery(option.label);
    onSelect(option);
    setIsOpen(false);
    setActiveIndex(null);
    inputRef.current?.focus();
  };

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

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

      case 'Enter':
        e.preventDefault();
        if (isOpen && activeIndex !== null && filteredOptions[activeIndex]) {
          handleOptionClick(filteredOptions[activeIndex]);
        }
        break;

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

      default:
        break;
    }
  };

  const activeOptionId =
    isOpen && activeIndex !== null && filteredOptions[activeIndex]
      ? `${comboboxId}-option-${filteredOptions[activeIndex].id}`
      : undefined;

  return (
    <div className="combobox-wrapper" style={{ position: 'relative', width: '300px' }}>
      <label htmlFor={`${comboboxId}-input`} style={{ display: 'block', marginBottom: '4px' }}>
        Choose an item
      </label>
      
      <div style={{ position: 'relative' }}>
        <input
          ref={inputRef}
          id={`${comboboxId}-input`}
          role="combobox"
          aria-expanded={isOpen}
          aria-haspopup="listbox"
          aria-controls={listboxId}
          aria-autocomplete="list"
          aria-activedescendant={activeOptionId}
          type="text"
          value={query}
          onChange={handleInputChange}
          onKeyDown={handleKeyDown}
          onFocus={() => setIsOpen(true)}
          placeholder={placeholder}
          style={{ width: '100%', padding: '8px', boxSizing: 'border-box' }}
        />
      </div>

      {isOpen && filteredOptions.length > 0 && (
        <ul
          ref={listRef}
          id={listboxId}
          role="listbox"
          style={{
            listStyle: 'none',
            margin: 0,
            padding: 0,
            position: 'absolute',
            top: '100%',
            left: 0,
            right: 0,
            border: '1px solid #ccc',
            backgroundColor: '#fff',
            maxHeight: '200px',
            overflowY: 'auto',
            zIndex: 1000,
          }}
        >
          {filteredOptions.map((option, index) => {
            const optionId = `${comboboxId}-option-${option.id}`;
            const isSelected = activeIndex === index;

            return (
              <li
                key={option.id}
                id={optionId}
                role="option"
                aria-selected={isSelected}
                onClick={() => handleOptionClick(option)}
                style={{
                  padding: '8px',
                  backgroundColor: isSelected ? '#007bff' : '#fff',
                  color: isSelected ? '#fff' : '#000',
                  cursor: 'pointer',
                }}
              >
                {option.label}
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
};

Keyboard Navigation Contract

To pass accessibility audits and provide a fluid user experience, your combobox must adhere to a strict keyboard interaction model:

Key Action
Tab Moves focus into the combobox input. If the popup is open, pressing Tab should typically close the popup and move focus to the next focusable element.
Arrow Down Opens the popup if closed. Moves visual highlight to the next item in the listbox. Loops back to the top if at the end.
Arrow Up Opens the popup if closed. Moves visual highlight to the previous item. Loops to the bottom if at the start.
Enter Selects the currently highlighted option, fills the input, and closes the popup.
Escape Closes the popup immediately. Optionally clears the input value depending on your UX requirements.

Pro-Tip: Never let ArrowUp or ArrowDown move the native text cursor position inside the <input />. Always call e.preventDefault() when intercepting these keys.


Enhancing Screen Reader Announcements

While aria-activedescendant informs screen readers of the currently highlighted option, dynamic changes to the result count can leave users in the dark.

For a truly bulletproof experience, consider adding a visually hidden live region (aria-live="polite") that announces search result changes:

<div className="sr-only" aria-live="polite" style={srOnlyStyles}>
  {isOpen ? `${filteredOptions.length} results available.` : ''}
</div>
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Testing Your Combobox

Building accessibility features without testing with real assistive technologies is a recipe for regressions. Make sure to test your newly created React component against:

  1. VoiceOver (macOS/iOS): Verify that typing reads characters, arrow keys announce options dynamically, and pressing Enter triggers selection.
  2. NVDA or JAWS (Windows): Ensure aria-controls successfully links the input to the listbox and announcement latency is minimal.
  3. Keyboard-Only Audit: Unplug your mouse entirely. Can you open, navigate, select, and close the combobox using solely Tab, Arrow keys, Enter, and Escape?

Conclusion

Implementing an accessible combobox requires careful synchronization of React state, precise ARIA attribute mapping, and rigorous keyboard event handling. By adopting the aria-activedescendant pattern, keeping focus pinned to the input, and honoring standard interaction models, you ensure that all users—regardless of ability or device—can seamlessly interact with your application’s autocomplete widgets.

More posts