All posts
1 Oct 2026

Type and Navigate: Building an Accessible Combobox in React from Scratch

A deep-dive technical guide on building a robust, accessible combobox and autocomplete component in React and TypeScript using ARIA listbox patterns and complex keyboard navigation.

Building a combobox or autocomplete component is one of the ultimate tests of frontend engineering. On the surface, it seems simple: an input field combined with a dropdown list. Beneath the surface, however, it is a complex composite widget that requires orchestrating focus management, dynamic filtering, strict ARIA attribute patterns, and intricate keyboard event handling.

Too often, developers reach for third-party libraries because native HTML <select> elements lack typeahead filtering, or because building custom dropdowns feels overwhelming. But relying on unstyled or poorly implemented custom components often breaks screen reader compatibility and keyboard navigation, shutting out users who rely on assistive technologies.

In this deep-dive technical guide, we will build a fully accessible, production-ready Combobox component in React and TypeScript from scratch. We will cover the WAI-ARIA Combobox pattern, manage complex composite state, implement robust keyboard navigation, and ensure screen readers announce options correctly.

The Anatomy of an Accessible Combobox

A combobox is a composite widget consisting of two primary elements: an input element that controls the widget (usually a text box) and a popup (typically a listbox or grid) that enables users to choose a value.

To make this accessible, we must adhere strictly to the WAI-ARIA 1.2 Combobox Pattern. The 1.2 specification simplified older, overly complex ARIA patterns by clarifying the relationship between the input and the popup:

  1. The Input: Acts as the controller. It requires role="combobox", aria-expanded, aria-autocomplete="list", and aria-controls pointing to the ID of the popup listbox.
  2. The Popup: Acts as the container for options with role="listbox".
  3. The Options: Individual selectable items within the listbox must have role="option" and a unique id.
  4. Active Descendant vs. Focus Management: We can manage focus either by moving DOM focus directly to the options (aria-activedescendant) or by keeping focus on the input while visually highlighting the active option. For robust cross-screen-reader support, managing focus on the input while utilizing aria-activedescendant is often the preferred approach for comboboxes.

Let’s implement this pattern using React hooks and TypeScript.

Setting Up Types and State

First, let’s define our TypeScript interfaces. A flexible combobox should accept generic data items, allowing us to pass complex objects while rendering strings or custom templates.

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

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

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

Next, we manage the internal state of our component. We need to track whether the listbox is open, the currently highlighted index for keyboard navigation, and the filtered subset of items based on user input.

export function Combobox<T extends ComboboxItem>({
  items,
  value,
  onChange,
  onSelect,
  placeholder,
  label,
}: ComboboxProps<T>) {
  const [isOpen, setIsOpen] = useState(false);
  const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
  
  const inputId = useId();
  const listboxId = useId();
  const labelId = useId();

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

  // Filter items based on the current input value
  const filteredItems = useMemo(() => {
    if (!value.trim()) return items;
    return items.filter((item) =>
      item.label.toLowerCase().includes(value.toLowerCase())
    );
  }, [items, value]);

  // Reset highlighted index when filtered items change
  useEffect(() => {
    setHighlightedIndex(-1);
  }, [filteredItems]);
...

Implementing Complex Keyboard Navigation

A great combobox lives and dies by its keyboard interactions. Users should be able to operate the entire widget without touching a mouse. Let’s map out the required keyboard event handlers:

  • ArrowDown: Opens the listbox if closed, or moves the highlight down to the next option. Wraps around or stops at the end.
  • ArrowUp: Opens the listbox if closed, or moves the highlight up to the previous option.
  • Enter: Selects the currently highlighted option and closes the listbox.
  • Escape: Closes the listbox. If already closed, clears the input value.
  • Tab: Closes the listbox and allows normal focus progression.

Let’s write the onKeyDown handler for our input element:

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

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

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

      case 'Escape':
        e.preventDefault();
        if (isOpen) {
          setIsOpen(false);
          setHighlightedIndex(-1);
        } else {
          onChange('');
        }
        break;

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

      default:
        break;
    }
  };

  const selectItem = (item: T) => {
    onChange(item.label);
    onSelect?.(item);
    setIsOpen(false);
    setHighlightedIndex(-1);
    inputRef.current?.focus();
  };

Wiring ARIA Attributes and Active Descendant

To ensure screen readers correctly announce options as the user navigates through them, we use the aria-activedescendant property. This property tells assistive technologies which descendant of the focused element is currently active, without needing to shift DOM focus away from the input element.

Every option in our listbox must have a deterministic ID matching the active index:

  const activeDescendantId =
    isOpen && highlightedIndex >= 0 && filteredItems[highlightedIndex]
      ? `${listboxId}-option-${highlightedIndex}`
      : undefined;

Let’s assemble the JSX structure, applying all necessary ARIA attributes:

  return (
    <div className="relative w-full max-w-sm">
      <label id={labelId} 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-autocomplete="list"
          aria-controls={listboxId}
          aria-activedescendant={activeDescendantId}
          aria-labelledby={labelId}
          value={value}
          onChange={(e) => {
            onChange(e.target.value);
            if (!isOpen) setIsOpen(true);
          }}
          onKeyDown={handleKeyDown}
          onFocus={() => setIsOpen(true)}
          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"
          aria-labelledby={labelId}
          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 isHighlighted = index === highlightedIndex;
            const optionId = `${listboxId}-option-${index}`;

            return (
              <li
                key={item.id}
                id={optionId}
                role="option"
                aria-selected={isHighlighted}
                onClick={() => selectItem(item)}
                onMouseEnter={() => setHighlightedIndex(index)}
                className={`px-3 py-2 cursor-pointer text-sm ${
                  isHighlighted ? 'bg-blue-600 text-white' : 'text-gray-900 hover:bg-gray-100'
                }`}
              >
                {item.label}
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}

Handling Edge Cases: Click-Outside and Scrolling

Building a robust component means handling real-world user behavior outside of standard happy paths. Two critical edge cases often break dropdown components: failing to close when clicking outside, and failing to keep the highlighted item scrolled into view.

1. Click-Outside Handling

We can close the dropdown when a user clicks outside the component boundary by attaching a document-level event listener:

  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. Auto-Scrolling Highlighted Options

When using keyboard arrows to navigate down a long list, the highlighted option can easily slip out of the visible container area. We can fix this by programmatically scrolling the active option into view whenever highlightedIndex changes:

  useEffect(() => {
    if (!isOpen || highlightedIndex < 0 || !listboxRef.current) return;

    const listNode = listboxRef.current;
    const itemNode = listNode.children[highlightedIndex] as HTMLElement;

    if (itemNode) {
      const itemTop = itemNode.offsetTop;
      const itemBottom = itemTop + itemNode.offsetHeight;
      const viewTop = listNode.scrollTop;
      const viewBottom = viewTop + listNode.clientHeight;

      if (itemTop < viewTop) {
        listNode.scrollTop = itemTop;
      } else if (itemBottom > viewBottom) {
        listNode.scrollTop = itemBottom - listNode.clientHeight;
      }
    }
  }, [highlightedIndex, isOpen]);

Pro-Tip: Wrapping container elements with proper ref bindings ensures your state synchronization remains tight and eliminates memory leaks during rapid unmount cycles.

Conclusion

Building a combobox from scratch requires careful attention to detail, but the reward is a component tailored precisely to your application’s design system with uncompromised accessibility.

By implementing the WAI-ARIA 1.2 Combobox specification, leveraging aria-activedescendant, mapping comprehensive keyboard event handlers, and managing edge cases like click-outside and auto-scrolling, you ensure that screen reader users and keyboard power users enjoy a seamless, frictionless experience.

Now that you have a rock-solid foundation, you can extend this component to support asynchronous data fetching, multi-select tags, or custom option rendering with full confidence.

More posts