All posts
17 Oct 2026

Building an Accessible Autocomplete in React: Combobox Patterns and Keyboard Navigation

A practical, implementation-focused guide to building a fully accessible React autocomplete combobox component with robust keyboard navigation and ARIA attributes.

Building an Accessible Autocomplete in React: Combobox Patterns and Keyboard Navigation

Building an autocomplete or combobox component seems straightforward at first glance: an input field, a dropdown list of suggestions, and some filtering logic. However, making that component accessible to screen reader users and navigable via keyboard-only interactions introduces significant complexity.

In this guide, we will build a production-grade, accessible Autocomplete component in React using TypeScript. We will focus on the W3C ARIA Combobox pattern, proper state management for aria-expanded and aria-activedescendant, and robust arrow-key navigation.


The Anatomy of an Accessible Combobox

A combobox is an input widget that controls another element, such as a listbox, that can dynamically pop up to help the user set the value of the input. According to the WAI-ARIA Authoring Practices Guide (APG), a robust combobox requires a clear separation of roles:

  1. The Combobox Wrapper: An element with role="combobox" (or placed directly on the input depending on the exact pattern version) that orchestrates the state.
  2. The Input: Where the user types. It requires attributes like aria-expanded, aria-autocomplete="list", and aria-controls pointing to the popup ID.
  3. The Popup Listbox: An element with role="listbox" containing options with role="option".

Choosing Your Pattern: aria-activedescendant vs. Focus Management

There are two primary patterns for keyboard navigation in autocomplete lists:

  • aria-activedescendant Pattern: Focus remains on the text input at all times. As the user presses the Down/Up arrows, an ID reference is passed to aria-activedescendant on the input, and the active option is visually highlighted via CSS. This is ideal because the user never loses their cursor position in the input field.
  • DOM Focus Management Pattern: Focus moves from the input directly into the listbox items. This can be cumbersome for typing because every arrow key shifts focus away from the input buffer.

We will implement the aria-activedescendant pattern for the smoothest user experience.


Setting Up the TypeScript Interfaces

Let’s define the props and state types for our component. We will accept a generic item type so our autocomplete can handle everything from simple strings to complex user objects.

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

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

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

Building the Component

Let’s construct the core component state, refs, and accessibility attributes.

export function AccessibleAutocomplete<T extends AutocompleteItem>({
  items,
  value,
  onChange,
  onSelect,
  placeholder,
  label,
}: AccessibleAutocompleteProps<T>) {
  const [isOpen, setIsOpen] = useState(false);
  const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);

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

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

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

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

  // ... Keyboard handlers go here

  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>
      
      {/* Combobox container */}
      <div className="relative">
        <input
          ref={inputRef}
          id={inputId}
          type="text"
          role="combobox"
          aria-expanded={isOpen && filteredItems.length > 0}
          aria-autocomplete="list"
          aria-controls={listboxId}
          aria-activedescendant={
            highlightedIndex >= 0 && isOpen
              ? `${listboxId}-item-${highlightedIndex}`
              : undefined
          }
          value={value}
          onChange={(e: ChangeEvent<HTMLInputElement>) => {
            onChange(e.target.value);
            setIsOpen(true);
          }}
          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"
        />

        {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"
          >
            {filteredItems.map((item, index) => {
              const isHighlighted = index === highlightedIndex;
              return (
                <li
                  key={item.id}
                  id={`${listboxId}-item-${index}`}
                  role="option"
                  aria-selected={isHighlighted}
                  onClick={() => {
                    onSelect(item);
                    setIsOpen(false);
                    setHighlightedIndex(-1);
                  }}
                  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>
  );
}

Implementing Robust Keyboard Navigation

Keyboard accessibility requires intercepting specific keydown events on the input. Users expect standard behaviors:

  • Arrow Down / Arrow Up: Open the listbox (if closed) and move the highlight index up or down.
  • Enter: Select the currently highlighted item.
  • Escape: Close the listbox.

Add the following onKeyDown handler to the 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]) {
        onSelect(filteredItems[highlightedIndex]);
        setIsOpen(false);
        setHighlightedIndex(-1);
      }
      break;

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

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

    default:
      break;
  }
};

Don’t forget to wire onKeyDown={handleKeyDown} directly onto the <input> element.


Handling Edge Cases and Polishing UX

1. Click Outside to Close

When a user clicks anywhere outside the autocomplete component, the dropdown list should close automatically. We can implement this with a simple document event listener inside a useEffect hook:

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);
}, []);

Wrap the entire component markup inside <div ref={containerRef}> to make this work.

2. Auto-Scrolling Highlighted Items

When navigating with Arrow Down and Arrow Up, if the list is long, the highlighted item might fall outside the visible scroll area of the listbox. We can ensure the active item scrolls into view automatically:

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

  const listElement = listboxRef.current;
  const itemElement = listElement.children[highlightedIndex] as HTMLElement;

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

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

Conclusion

Creating an accessible combobox requires careful synchronization between visual state, keyboard focus, and ARIA attributes. By leveraging the aria-activedescendant pattern, we maintain standard input cursor behavior while offering seamless keyboard navigation through dropdown suggestions.

When building design systems, always test your components with screen readers (such as VoiceOver on macOS or NVDA on Windows) alongside keyboard-only navigation to ensure all users receive a first-class experience.

More posts