All posts
5 Oct 2026

Dropdown Dilemmas: Building a WAI-ARIA Compliant Autocomplete and Combobox in React

Master the complexities of ARIA attributes, roving tabindex, arrow key navigation, and async filtering to build a production-ready, fully accessible React combobox.

Building a custom combobox or autocomplete component is a classic rite of passage in front-end development. At first glance, it seems simple: an <input> field coupled with a dropdown list of suggestions. However, once you peek under the hood, you quickly realize you are building a miniature operating system widget.

To build a truly production-grade component, you must account for asynchronous data fetching, keyboard navigation, focus management, and strict WAI-ARIA compliance. If you fail on any of these fronts, you risk alienating screen reader users and keyboard-only power users.

In this guide, we will break down the mechanics of the WAI-ARIA Combobox pattern, dissect complex keyboard interactions, and build a fully accessible, type-safe React combobox component from scratch using TypeScript.

Understanding the Anatomy of a WAI-ARIA Combobox

The WAI-ARIA specification defines a combobox as a composite widget that combines a single-line text input with a popup (usually a listbox) that helps the user set the value of the input.

To make this accessible to assistive technologies, we must establish a clear programmatic relationship between the input, the wrapper, and the popup list. Here are the core ARIA attributes you need:

  • role="combobox": Applied to the input itself (or a wrapper container, depending on the exact pattern variant, though putting it directly on the input or managing aria-expanded on the input is standard for modern implementations).
  • aria-expanded: A boolean (true or false) indicating whether the popup list is currently visible.
  • aria-haspopup: Set to "listbox" to inform screen readers that activating the input opens a listbox popup.
  • aria-controls: Points to the id of the popup listbox, linking the input’s focus state directly to the dropdown content.
  • aria-activedescendant: Points to the id of the currently highlighted option inside the listbox, allowing screen readers to announce the active option without shifting DOM focus away from the input.
  • role="listbox": Applied to the dropdown container.
  • role="option": Applied to each item inside the listbox, accompanied by aria-selected to indicate selection states.

Note on Focus Management: A common anti-pattern is moving DOM focus from the input to the list items when arrowing down. In a combobox, focus remains on the input at all times. We use aria-activedescendant to visually and programmatically signal the active option while the input maintains focus.

The Keyboard Interaction Contract

Keyboard accessibility isn’t an afterthought; it is the core user experience for many. Users expect a predictable set of key bindings when interacting with a combobox:

  1. ArrowDown / ArrowUp: If the popup is closed, opens the popup and optionally highlights the first or last item. If open, cycles focus/highlighting through the list options.
  2. Enter: Selects the currently highlighted option and closes the popup.
  3. Escape: Closes the popup. If the popup is already closed, optionally clears the input value.
  4. Tab: Closes the popup and commits the current value or selection, allowing normal focus progression to the next focusable element on the page.
  5. Typing (Char keys): Filters the list dynamically based on the input value.

Building the Production-Ready React Component

Let’s put these principles into action. Below is a complete, TypeScript-powered React component that handles async filtering, keyboard navigation, aria-activedescendant, and outside clicks.

The TypeScript Implementation

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

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

interface ComboboxProps {
  options: Option[];
  value: string;
  onChange: (value: string) => void;
  onSelect: (option: Option) => void;
  placeholder?: string;
  isLoading?: boolean;
  label: string;
}

export const AccessibleCombobox: React.FC<ComboboxProps> = ({
  options,
  value,
  onChange,
  onSelect,
  placeholder = "Search...",
  isLoading = false,
  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 listboxId = `combobox-listbox-${uniqueId}`;
  const labelId = `combobox-label-${uniqueId}`;

  // Handle outside clicks to close dropdown
  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);
  }, []);

  // Reset active index when options change or dropdown closes
  useEffect(() => {
    if (!isOpen) {
      setActiveIndex(-1);
    } else if (options.length > 0 && activeIndex >= options.length) {
      setActiveIndex(0);
    }
  }, [options, isOpen, activeIndex]);

  const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    onChange(e.target.value);
    if (!isOpen) setIsOpen(true);
    setActiveIndex(-1);
  };

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

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

      case "Enter":
        e.preventDefault();
        if (isOpen && activeIndex >= 0 && options[activeIndex]) {
          onSelect(options[activeIndex]);
          setIsOpen(false);
        }
        break;

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

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

      default:
        break;
    }
  };

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

  const activeOptionId =
    isOpen && activeIndex >= 0 && options[activeIndex]
      ? `option-${uniqueId}-${options[activeIndex].id}`
      : undefined;

  return (
    <div className="relative w-full max-w-sm">
      <label
        id={labelId}
        htmlFor={`input-${uniqueId}`}
        className="block text-sm font-medium text-gray-700 mb-1"
      >
        {label}
      </label>

      <div className="relative">
        <input
          ref={inputRef}
          id={`input-${uniqueId}`}
          role="combobox"
          aria-expanded={isOpen}
          aria-haspopup="listbox"
          aria-controls={listboxId}
          aria-activedescendant={activeOptionId}
          aria-labelledby={labelId}
          type="text"
          value={value}
          onChange={handleInputChange}
          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"
        />

        {isOpen && (
          <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"
          >
            {isLoading ? (
              <li className="px-4 py-2 text-sm text-gray-500">Loading...</li>
            ) : options.length === 0 ? (
              <li className="px-4 py-2 text-sm text-gray-500">No results found</li>
            ) : (
              options.map((option, index) => {
                const isSelected = value === option.label;
                const isActive = index === activeIndex;
                const optionId = `option-${uniqueId}-${option.id}`;

                return (
                  <li
                    key={option.id}
                    id={optionId}
                    role="option"
                    aria-selected={isSelected}
                    onClick={() => handleOptionClick(option)}
                    onMouseEnter={() => setActiveIndex(index)}
                    className={`px-4 py-2 text-sm cursor-pointer ${
                      isActive ? "bg-blue-600 text-white" : "text-gray-900"
                    } ${isSelected && !isActive ? "bg-blue-50" : ""}`}
                  >
                    {option.label}
                  </li>
                );
              })
            )}
          </ul>
        )}
      </div>
    </div>
  );
};

Handling Asynchronous Filtering and Race Conditions

When dealing with autocomplete fields that fetch suggestions from an API, network latency introduces complexity. If a user types quickly, network requests can resolve out of order, leading to stale suggestions overwriting newer ones.

To handle asynchronous filtering gracefully, pair your combobox with a debounced search hook or utilize an AbortController to cancel pending fetch requests:

import { useState, useEffect } from 'react';

export function useAsyncAutocomplete(fetcher: (query: string, signal: AbortSignal) => Promise<Option[]>) {
  const [query, setQuery] = useState('');
  const [options, setOptions] = useState<Option[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const controller = new AbortController();
    
    if (!query.trim()) {
      setOptions([]);
      setIsLoading(false);
      return;
    }

    setIsLoading(true);

    const timer = setTimeout(async () => {
      try {
        const results = await fetcher(query, controller.signal);
        setOptions(results);
      } catch (error: any) {
        if (error.name !== 'AbortError') {
          console.error('Failed to fetch autocomplete options', error);
        }
      } finally {
        setIsLoading(false);
      }
    }, 300);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query, fetcher]);

  return { query, setQuery, options, isLoading };
}

By integrating AbortController, every keystroke that triggers a new query immediately cancels the inflight request of the previous keystroke, preserving network bandwidth and preventing state corruption.

Testing Your Implementation with Screen Readers

Automated accessibility testing tools like axe-core will catch missing aria-label attributes or broken ID references, but they cannot evaluate experiential accessibility. Always test your combobox manually using real assistive tech:

  • macOS: VoiceOver (Cmd + F5)
  • Windows: NVDA (Free and open source)
  • Mobile: TalkBack (Android) or VoiceOver (iOS)

Verify that:

  1. Focusing the input announces the label and indicates it is an expanded/collapsed combobox.
  2. Pressing ArrowDown announces the first item in the list without shifting keyboard focus.
  3. Selecting an item updates the input value and cleanly closes the popup.

Conclusion

Building a custom combobox in React is an exercise in balancing design flexibility with strict behavioral contracts. By respecting the WAI-ARIA specification, meticulously managing aria-activedescendant, and supporting robust keyboard navigation patterns, you ensure that your components are delightful and inclusive for every user.

More posts