All posts
14 Oct 2026

Combobox and Autocomplete in React: Building an Accessible Typeahead from Scratch

A comprehensive technical guide to building a production-ready, fully accessible combobox and autocomplete component in React using modern ARIA attributes, robust keyboard navigation, and TypeScript.

Combobox and Autocomplete in React: Building an Accessible Typeahead from Scratch

When developers are tasked with building a combobox or autocomplete component, they often start with a simple <input> element, attach an onChange handler, map over an array of filtered results, and render an absolute-positioned <ul>.

While this works for sighted mouse users, it is a nightmare for screen reader users and keyboard-only navigators. A truly accessible combobox requires a delicate orchestration of WAI-ARIA authoring practices, precise keyboard event handling, focus management, and dynamic state synchronization.

In this guide, we will build a robust, accessible autocomplete combobox in React and TypeScript from scratch—no third-party UI libraries required.


The Anatomy of an Accessible Combobox

Before writing code, we need to understand the structural contract between the DOM and assistive technologies. According to the WAI-ARIA 1.2 specification, a combobox is a composite widget consisting of two primary elements:

  1. The Combobox Input: An input element (or an element with role="combobox") that controls a popup.
  2. The Popup Listbox: A container with role="listbox" containing options with role="option".

To bridge these two elements, the input uses several critical ARIA attributes:

  • aria-expanded: Tells the screen reader whether the popup list is currently open (true) or closed (false).
  • aria-haspopup: Set to "listbox" to inform the user agent that this input controls a listbox popup.
  • aria-controls: Points to the DOM id of the popup listbox.
  • aria-activedescendant: Points to the DOM id of the currently highlighted option within the listbox, allowing focus to remain on the input while the screen reader announces the active option.

Setting Up the TypeScript State and Types

Let’s start by defining our component props and internal state. We want our component to accept an array of items, a filtering function, and a selection callback.

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

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

interface AccessibleAutocompleteProps {
  items: AutocompleteItem[];
  onSelect: (item: AutocompleteItem) => void;
  placeholder?: string;
  label: string;
}

Next, inside our component, we manage the input value, the open/closed state of the dropdown, and the index of the currently highlighted option (activeIndex).

export const AccessibleAutocomplete: React.FC<AccessibleAutocompleteProps> = ({
  items,
  onSelect,
  placeholder,
  label,
}) => {
  const [query, setQuery] = useState('');
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState<number>(-1);

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

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

  // ... component continues
};

Implementing Keyboard Navigation

Keyboard accessibility is the cornerstone of a functional combobox. Users must be able to open the menu, navigate results, select an item, and close the menu without ever touching a mouse.

Here is the exact keyboard matrix we need to support:

  • ArrowDown / ArrowUp: Opens the listbox (if closed) and moves the active highlight index down or up.
  • Enter: Selects the currently highlighted option and closes the listbox.
  • Escape: Closes the listbox and clears focus or input depending on state.
  • Tab: Closes the listbox and allows normal page navigation.

Let’s implement the onKeyDown handler:

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

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

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

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

    case 'Tab':
      setIsOpen(false);
      break;

    default:
      break;
  }
};

const selectItem = (item: AutocompleteItem) => {
  setQuery(item.label);
  onSelect(item);
  setIsOpen(false);
  setActiveIndex(-1);
  inputRef.current?.blur();
};

The Magic of aria-activedescendant

A common anti-pattern in custom dropdowns is moving browser focus (document.activeElement) to the options inside the listbox. When you move focus away from the input, the user stops typing, and screen readers often become confused about context.

The modern ARIA pattern keeps focus strictly on the <input> element at all times. To let screen readers know which option is currently targeted, we assign an id to the active option element and pass that id to the input’s aria-activedescendant attribute.

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

Assembling the JSX and ARIA Markup

Now let’s put everything together into our return statement, ensuring every semantic requirement is met.

return (
  <div className="relative w-full max-w-sm">
    {/* Accessible label associated via htmlFor */}
    <label htmlFor={`${listboxId}-input`} className="block text-sm font-medium text-gray-700 mb-1">
      {label}
    </label>

    <div className="relative">
      <input
        ref={inputRef}
        id={`${listboxId}-input`}
        type="text"
        role="combobox"
        aria-expanded={isOpen}
        aria-haspopup="listbox"
        aria-controls={listboxId}
        aria-activedescendant={activeDescendantId}
        aria-autocomplete="list"
        value={query}
        onChange={(e) => {
          setQuery(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"
      />

      {isOpen && filteredItems.length > 0 && (
        <ul
          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"
        >
          {filteredItems.map((item, index) => {
            const optionId = `${listboxId}-option-${index}`;
            const isSelected = activeIndex === index;

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

Handling Edge Cases and Polish

To make this component truly production-ready, consider two additional enhancements:

1. Click Outside to Close

If a user clicks anywhere outside the combobox component while the listbox is open, it should gracefully close.

const containerRef = useRef<HTMLDivElement>(null);

React.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. Live Region Announcements for Screen Readers

While aria-activedescendant handles option announcements in most modern screen readers, adding an aria-live="polite" region summarizing the number of matching results can greatly improve the experience for visually impaired users.

<div className="sr-only" aria-live="polite" aria-atomic="true">
  {isOpen ? `${filteredItems.length} results available.` : ''}
</div>

Conclusion

Building accessible UI components requires looking beyond visual design and embracing the standards set by the WAI-ARIA authoring practices. By keeping focus on the input, utilizing aria-activedescendant, managing keyboard events meticulously, and correctly wiring structural ARIA roles, you provide a seamless, frustration-free experience for every user regardless of how they navigate the web.

More posts