All posts
26 Sep 2026

Type, Arrow, Select: Building an Accessible Combobox in React

Learn how to build a fully accessible, WCAG-compliant combobox and autocomplete component in React and TypeScript featuring keyboard navigation and ARIA attributes.

Type, Arrow, Select: Building an Accessible Combobox in React

Building a combobox—often referred to as an autocomplete or typeahead—is one of the most deceptively complex UI challenges in frontend development. At first glance, it looks like a simple text input paired with a dropdown. But once you factor in accessibility (a11y), keyboard navigation, screen reader announcements, focus management, and TypeScript safety, it quickly evolves into a sophisticated state machine.

In this article, we will walk through the architecture of a robust, accessible combobox in React and TypeScript. We will cover listbox expansion, typeahead filtering, aria-activedescendant management, and complete keyboard interactions that adhere strictly to the WAI-ARIA Authoring Practices Guide (APG).


Understanding the Combobox Anatomy

Before writing any code, we need to understand the structural contract between the user, the screen reader, and the DOM. A standard ARIA combobox consists of three main elements:

  1. The Combobox Input (role="combobox"): A text input where users type their query. It controls the popup state via aria-expanded and points to the currently active option via aria-activedescendant.
  2. The Listbox Popup (role="listbox"): A container holding the filtered options (role="option").
  3. The Options (role="option"): Individual selectable items within the listbox.

The Two Accessibility Patterns

There are two primary ways to manage focus in an ARIA combobox:

  • aria-activedescendant pattern: Focus remains on the input element at all times, but the aria-activedescendant attribute on the input dynamically updates to match the ID of the currently highlighted option in the listbox. Screen readers announce the active option as it changes.
  • Focus delegation pattern: Focus moves directly from the input into the listbox items.

For a smooth typing experience where the cursor remains in the text input, the aria-activedescendant pattern is the gold standard. This is the architecture we will build.


Setting Up the TypeScript Interfaces

Let’s start by defining our component props and data types. We want our combobox to be generic so it can accept any data shape, provided we can extract a string label from it.

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

export interface ComboboxItem {
  id: string;
  label: string;
  [key: string]: any;
}

export interface ComboboxProps<T extends ComboboxItem> {
  items: T[];
  value: string;
  onChange: (value: string) => void;
  onSelect: (item: T) => void;
  placeholder?: string;
  label: string;
}

Building the Core Component State

We need to track several pieces of state: whether the listbox is open, the index of the currently highlighted option, and the filtered subset of items based on user input.

export function Combobox<T extends ComboboxItem>({
  items,
  value,
  onChange,
  onSelect,
  placeholder = 'Search...',
  label,
}: ComboboxProps<T>) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState<number>(-1);
  
  const inputId = useId();
  const listboxId = useId();
  const inputRef = useRef<HTMLInputElement>(null);
  const listboxRef = useRef<HTMLUListElement>(null);

  // Filter items based on the input value
  const filteredItems = items.filter((item) =>
    item.label.toLowerCase().includes(value.toLowerCase())
  );

  // Generate unique IDs for options to support aria-activedescendant
  const getOptionId = (index: number) => `${listboxId}-option-${index}`;

Implementing Keyboard Navigation

Keyboard accessibility is where most custom dropdowns fail. Users expect specific behaviors when pressing keys like ArrowDown, ArrowUp, Enter, Escape, and Home/End.

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

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

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

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

      case 'Home':
        if (isOpen) {
          e.preventDefault();
          setActiveIndex(0);
        }
        break;

      case 'End':
        if (isOpen) {
          e.preventDefault();
          setActiveIndex(filteredItems.length - 1);
        }
        break;

      default:
        break;
    }
  };

Managing Focus, Outside Clicks, and Scrolling

When using aria-activedescendant, the highlighted item in the listbox must remain scrolled into view as the user navigates with arrow keys. We also need to close the dropdown when clicking outside the component.

  // Scroll active option into view when activeIndex changes
  useEffect(() => {
    if (isOpen && activeIndex >= 0 && listboxRef.current) {
      const activeNode = listboxRef.current.children[activeIndex] as HTMLElement;
      if (activeNode) {
        activeNode.scrollIntoView({ block: 'nearest' });
      }
    }
  }, [activeIndex, isOpen]);

  // Close listbox when clicking outside
  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);
        setActiveIndex(-1);
      }
    };

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

Assembling the JSX and ARIA Attributes

Now we put everything together, ensuring all required ARIA attributes are wired up correctly. Notice how aria-activedescendant dynamically evaluates to the ID of the active option or undefined when nothing is highlighted.

  return (
    <div className="relative w-full max-w-sm">
      <label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
        {label}
      </label>
      
      <div className="relative">
        <input
          ref={inputRef}
          id={inputId}
          type="text"
          role="combobox"
          aria-expanded={isOpen}
          aria-haspopup="listbox"
          aria-controls={listboxId}
          aria-autocomplete="list"
          aria-activedescendant={
            isOpen && activeIndex >= 0 ? getOptionId(activeIndex) : undefined
          }
          value={value}
          onChange={(e: ChangeEvent<HTMLInputElement>) => {
            onChange(e.target.value);
            setIsOpen(true);
            setActiveIndex(0);
          }}
          onFocus={() => setIsOpen(true)}
          onKeyDown={handleKeyDown}
          placeholder={placeholder}
          className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
        />
      </div>

      {isOpen && filteredItems.length > 0 && (
        <ul
          ref={listboxRef}
          id={listboxId}
          role="listbox"
          className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto focus:outline-none"
        >
          {filteredItems.map((item, index) => {
            const isSelected = item.label === value;
            const isActive = index === activeIndex;

            return (
              <li
                id={getOptionId(index)}
                key={item.id}
                role="option"
                aria-selected={isSelected}
                className={`px-3 py-2 cursor-pointer text-sm ${
                  isActive ? 'bg-blue-600 text-white' : 'text-gray-900'
                } ${isSelected && !isActive ? 'font-semibold bg-gray-50' : ''}`}
                onMouseEnter={() => setActiveIndex(index)}
                onClick={() => {
                  onSelect(item);
                  setIsOpen(false);
                  setActiveIndex(-1);
                  inputRef.current?.focus();
                }}
              >
                {item.label}
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}

Testing Your Combobox Accessibility

Building accessibility features into a component requires rigorous verification. Here is a quick checklist to ensure your combobox meets accessibility standards:

  1. Screen Reader Test: Turn on VoiceOver (macOS) or NVDA (Windows). Focus the input, type a search term, and use the Down Arrow key. The screen reader should clearly announce each item and its selection state.
  2. Keyboard Traversal: Ensure you can open the menu with ArrowDown, navigate through options, select an item with Enter, and dismiss the menu with Escape without touching a mouse.
  3. Focus Ring: Verify that the input maintains a clear, high-contrast focus indicator at all times.

Conclusion

Creating an accessible combobox in React requires careful synchronization of state, DOM references, and ARIA semantics. By implementing the aria-activedescendant pattern alongside comprehensive keyboard event handlers, you provide an inclusive, fluid experience for keyboard and screen reader users alike.

Feel free to extend this component with features like asynchronous remote data fetching, multi-selection tags, or custom rendering templates for complex option cards!

More posts