All posts
21 Sep 2026

The ComboBox Conundrum: Building an Accessible Autocomplete Component in React

{"title": "The ComboBox Conundrum: Building an Accessible Autocomplete Component in React", "summary": "Learn how to build a fully accessible, keyboard-navigable combobox and autocomplete component in React and TypeScript from scratch, following strict WAI-ARIA authoring practices.

{“title”: “The ComboBox Conundrum: Building an Accessible Autocomplete Component in React”, “summary”: “Learn how to build a fully accessible, keyboard-navigable combobox and autocomplete component in React and TypeScript from scratch, following strict WAI-ARIA authoring practices.”, “tags”: [“Accessibility”, “React”, “TypeScript”, “UI Components”, “Frontend”], “body”: “## Introduction

If you have ever attempted to build a custom combobox or autocomplete component from scratch, you likely ran into a wall of complexity. At first glance, it seems simple: an input field, a dropdown list, and some filtering logic. But once you factor in screen readers, robust keyboard navigation, dynamic focus management, and typeahead behaviors, the seemingly simple UI component turns into an intricate state machine.

Many developers default to heavy component libraries to solve this, but those libraries often ship with massive bundle sizes and rigid styling paradigms. Building your own accessible combobox gives you absolute control over styling, behavior, and performance.

In this guide, we will break down the WAI-ARIA Authoring Practices Guide (APG) for a combobox and implement a bulletproof, keyboard-navigable autocomplete component in React and TypeScript without relying on third-party UI libraries.


Understanding the WAI-ARIA Combobox Pattern

Before writing a single line of React code, we need to understand what makes a combobox a combobox in the eyes of assistive technologies. According to the WAI-ARIA 1.2 specification, a combobox is an input widget that controls another element, such as a listbox or grid, that can dynamically pop up to help the user set the value of the input.

To make our component fully accessible, we must manage a delicate interplay of ARIA attributes:

  1. The Input Element:

    • role=\"combobox\": Identifies the element as a combobox.
    • aria-expanded: Indicates whether the popup (listbox) is currently open (true or false).
    • aria-haspopup=\"listbox\": Informs the screen reader that the popup is a listbox.
    • aria-controls: Points to the id of the popup element.
    • aria-autocomplete=\"list\": Specifies that the suggestion behavior provides a list of choices.
    • aria-activedescendant: Identifies the id of the currently highlighted option within the listbox (crucial for screen reader announcements without shifting actual DOM focus).
  2. The Listbox Element:

    • role=\"listbox\": Identifies the popup container.
    • id: Must match the aria-controls value on the input.
  3. The Option Elements:

    • role=\"option\": Identifies each selectable item.
    • aria-selected: Indicates whether the option is currently focused or selected.
    • id: Unique identifier referenced by the input’s aria-activedescendant.

Defining the TypeScript Interfaces

Let’s start by setting up our types. We want our component to be generic enough to handle arbitrary items while strictly typing our state transitions and props.

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

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

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

State Management and Hook Architecture

Our combobox needs to track several distinct pieces of state:

  • Whether the popup listbox is open.
  • The index of the currently highlighted option (for keyboard navigation).
  • The filtered items based on the user’s input.
export const AccessibleCombobox: React.FC<ComboboxProps> = ({
  items,
  value,
  onChange,
  onSelect,
  placeholder = 'Search...',
  label,
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState<number>(-1);

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

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

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

  // Reset active index when filtered items change or list closes
  useEffect(() => {
    setActiveIndex(-1);
  }, [value, isOpen]);

  // ... event handlers go here
};

Implementing Robust Keyboard Navigation

Keyboard navigation is where most custom comboboxes fail. Users expect specific behaviors:

  • ArrowDown / ArrowUp: Moves focus through the suggestion list without moving the text cursor out of the input.
  • Enter: Selects the currently highlighted option.
  • Escape: Closes the listbox.
  • Home / End: Jumps to the first or last suggestion.

Here is how we implement this using aria-activedescendant:

const handleKeyDown = (e: 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) {
        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':
      // Allow natural tab behavior, but close the listbox
      setIsOpen(false);
      break;

    default:
      if (!isOpen) setIsOpen(true);
      break;
  }
};

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

Why aria-activedescendant? Moving standard DOM focus (document.activeElement) to dropdown options while a user is typing breaks the input flow. aria-activedescendant keeps DOM focus firmly on the <input>, while telling screen readers to announce the element whose ID matches the active state.


Assembling the JSX Structure

Now let’s wire everything together into a clean, accessible JSX template.

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 ? `${listboxId}-item-${activeIndex}` : undefined
        }
        value={value}
        onChange={(e) => {
          onChange(e.target.value);
          if (!isOpen) setIsOpen(true);
        }}
        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\"
      />

      {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 isHighlighted = index === activeIndex;
            const itemId = `${listboxId}-item-${index}`;

            return (
              <li
                key={item.id}
                id={itemId}
                role=\"option\"
                aria-selected={isHighlighted}
                onMouseEnter={() => setActiveIndex(index)}
                onClick={() => selectItem(item)}
                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>
  </div>
);

Handling Click-Outside and Focus Loss

A common edge case in autocomplete components is managing when the popup should close. If a user clicks outside the component or tabs away, the listbox must disappear.

We can implement a simple document-level click listener using an effect:

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 from scratch requires careful adherence to WAI-ARIA specifications, meticulous keyboard event handling, and thoughtful state management. By leveraging aria-activedescendant and keeping DOM focus where it belongs (on the input), you ensure that screen reader users and keyboard-only users experience a seamless interaction.

With this foundation in place, you can expand your component to support asynchronous data fetching, multi-select tags, or custom option rendering without sacrificing accessibility or bloating your project with heavy dependencies.”}

More posts