All posts
24 Sep 2026

Type, Arrow, Select: Building an Accessible Combobox and Autocomplete in React

A comprehensive guide to building a fully accessible, keyboard-navigable combobox and autocomplete component in React using TypeScript and WAI-ARIA best practices.

Type, Arrow, Select: Building an Accessible Combobox and Autocomplete in React

Welcome back to our UI component series! In previous installments, we’ve tackled modals, tooltips, and tabs. Today, we are stepping up the complexity. We are building a robust, production-ready Combobox and Autocomplete component in React.

Autocomplete components are deceptive. At first glance, it’s just an <input> paired with a dropdown. But when you factor in keyboard navigation, asynchronous data fetching, dynamic ARIA attributes, focus management, and screen reader announcements, it becomes one of the most complex patterns in frontend engineering.

In this guide, we will leverage TypeScript, React hooks, and the WAI-ARIA 1.2 Combobox Pattern to build an accessible component from scratch. No heavy third-party UI libraries required.


The Anatomy of an Accessible Combobox

According to the WAI-ARIA Authoring Practices Guide (APG), 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.

To make this accessible, we must manage a delicate dance of states and attributes:

  1. The Input (role="combobox"): Controls the popup state and points to the currently active option.
  2. The Popup Listbox (role="listbox"): Contains the selectable options.
  3. Options (role="option"): Individual choices within the listbox.
  4. Live Regions (aria-live): Silently communicate state changes (like search results count) to screen reader users.

Let’s look at the core state interface we need for our React component.

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

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

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

Step 1: Managing State and Unique IDs

Screen readers rely heavily on ID referencing (aria-controls, aria-activedescendant, aria-labelledby). We’ll use React’s useId hook to guarantee unique IDs across the DOM, preventing collisions if multiple comboboxes render on the same page.

export const Combobox: React.FC<ComboboxProps> = ({
  options,
  value,
  onChange,
  placeholder = 'Search...',
  label,
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [query, setQuery] = useState(value);
  const [activeIndex, setActiveIndex] = useState<number | null>(null);

  const comboboxId = useId();
  const listboxId = `${comboboxId}-listbox`;
  const inputId = `${comboboxId}-input`;
  const labelId = `${comboboxId}-label`;
  const statusId = `${comboboxId}-status`;

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

  // Filter options based on user input
  const filteredOptions = options.filter((option) =>
    option.label.toLowerCase().includes(query.toLowerCase())
  );

  // ... rest of the component
};

Step 2: Implementing Keyboard Navigation

A mouse-only combobox fails accessibility audits instantly. Keyboard users rely on specific patterns:

  • Down Arrow / Up Arrow: Open the listbox (if closed) and move focus/highlight through options.
  • Escape: Close the listbox and clear or revert the input.
  • Enter: Select the currently highlighted option.
  • Home / End: Jump to the first or last option.

Here is how we wire up the onKeyDown handler on our input element:

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

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

    case 'Enter':
      e.preventDefault();
      if (isOpen && activeIndex !== null && filteredOptions[activeIndex]) {
        selectOption(filteredOptions[activeIndex]);
      }
      break;

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

    case 'Home':
      if (isOpen && filteredOptions.length > 0) {
        e.preventDefault();
        setActiveIndex(0);
      }
      break;

    case 'End':
      if (isOpen && filteredOptions.length > 0) {
        e.preventDefault();
        setActiveIndex(filteredOptions.length - 1);
      }
      break;

    default:
      break;
  }
};

Step 3: Mastering aria-activedescendant

When building a combobox, you have two architectural choices for keyboard focus:

  1. Move DOM focus to the listbox options.
  2. Keep DOM focus on the <input> and use aria-activedescendant to programmatically tell screen readers which listbox option is currently “active”.

Option 2 is the modern standard. It keeps the typing context anchored in the input while visually and programmatically highlighting options in the dropdown.

To implement this, we generate a unique ID for the active option and pass it to the input via aria-activedescendant:

const activeOptionId =
  isOpen && activeIndex !== null && filteredOptions[activeIndex]
    ? `${comboboxId}-option-${filteredOptions[activeIndex].id}`
    : undefined;

On the input element:

<input
  id={inputId}
  ref={inputRef}
  type="text"
  role="combobox"
  aria-expanded={isOpen}
  aria-autocomplete="list"
  aria-controls={listboxId}
  aria-activedescendant={activeOptionId}
  aria-labelledby={labelId}
  value={query}
  onChange={(e) => {
    setQuery(e.target.value);
    setIsOpen(true);
    setActiveIndex(0);
  }}
  onKeyDown={handleKeyDown}
  placeholder={placeholder}
/>

Step 4: Adding ARIA Live Regions for Search Feedback

Screen reader users typing into an autocomplete need to know how many results are available without constantly interrupting their flow. We achieve this using a polite ARIA live region (aria-live="polite") placed off-screen or rendered dynamically.

{/* Live region for screen readers to announce result counts */}
<div
  id={statusId}
  aria-live="polite"
  aria-atomic="true"
  className="sr-only"
>
  {isOpen && `${filteredOptions.length} results available.`}
</div>

(Note: .sr-only is a standard utility class that visually hides an element while keeping it fully accessible to screen readers.)


Step 5: Putting It All Together

Here is the complete, integrated React component incorporating our state management, event listeners, listbox structure, and accessibility markup:

import React, { useState, useRef, useId } from 'react';
import './Combobox.css';

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

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

export const Combobox: React.FC<ComboboxProps> = ({
  options,
  value,
  onChange,
  placeholder = 'Search...',
  label,
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [query, setQuery] = useState(value);
  const [activeIndex, setActiveIndex] = useState<number | null>(null);

  const comboboxId = useId();
  const listboxId = `${comboboxId}-listbox`;
  const inputId = `${comboboxId}-input`;
  const labelId = `${comboboxId}-label`;
  const statusId = `${comboboxId}-status`;

  const inputRef = useRef<HTMLInputElement>(null);

  const filteredOptions = options.filter((option) =>
    option.label.toLowerCase().includes(query.toLowerCase())
  );

  const selectOption = (option: Option) => {
    setQuery(option.label);
    onChange(option.value);
    setIsOpen(false);
    setActiveIndex(null);
    inputRef.current?.focus();
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
          setActiveIndex(0);
        } else {
          setActiveIndex((prev) =>
            prev === null || prev >= filteredOptions.length - 1 ? 0 : prev + 1
          );
        }
        break;
      case 'ArrowUp':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
          setActiveIndex(filteredOptions.length - 1);
        } else {
          setActiveIndex((prev) =>
            prev === null || prev <= 0 ? filteredOptions.length - 1 : prev - 1
          );
        }
        break;
      case 'Enter':
        e.preventDefault();
        if (isOpen && activeIndex !== null && filteredOptions[activeIndex]) {
          selectOption(filteredOptions[activeIndex]);
        }
        break;
      case 'Escape':
        e.preventDefault();
        setIsOpen(false);
        setActiveIndex(null);
        break;
      default:
        break;
    }
  };

  const activeOptionId =
    isOpen && activeIndex !== null && filteredOptions[activeIndex]
      ? `${comboboxId}-option-${filteredOptions[activeIndex].id}`
      : undefined;

  return (
    <div className="combobox-wrapper">
      <label id={labelId} htmlFor={inputId} className="combobox-label">
        {label}
      </label>
      
      <div className="combobox-input-container">
        <input
          id={inputId}
          ref={inputRef}
          type="text"
          role="combobox"
          aria-expanded={isOpen}
          aria-autocomplete="list"
          aria-controls={listboxId}
          aria-activedescendant={activeOptionId}
          aria-labelledby={labelId}
          value={query}
          onChange={(e) => {
            setQuery(e.target.value);
            setIsOpen(true);
            setActiveIndex(0);
          }}
          onKeyDown={handleKeyDown}
          placeholder={placeholder}
        />

        <div id={statusId} aria-live="polite" className="sr-only">
          {isOpen ? `${filteredOptions.length} suggestions available.` : ''}
        </div>
      </div>

      {isOpen && (
        <ul
          id={listboxId}
          role="listbox"
          aria-labelledby={labelId}
          className="combobox-listbox"
        >
          {filteredOptions.length === 0 ? (
            <li className="combobox-option no-results" role="presentation">
              No results found
            </li>
          ) : (
            filteredOptions.map((option, index) => {
              const optionId = `${comboboxId}-option-${option.id}`;
              const isSelected = value === option.value;
              const isActive = index === activeIndex;

              return (
                <li
                  id={optionId}
                  key={option.id}
                  role="option"
                  aria-selected={isSelected}
                  className={`combobox-option ${
                    isActive ? 'active' : ''
                  } ${isSelected ? 'selected' : ''}`}
                  onClick={() => selectOption(option)}
                  onMouseEnter={() => setActiveIndex(index)}
                >
                  {option.label}
                </li>
              );
            })
          )}
        </ul>
      )}
    </div>
  );
};

Styling for Clarity and Focus States

Accessibility isn’t just about screen readers; it’s also about visual clarity for low-vision users and keyboard navigators. Here is a baseline CSS snippet to style our states effectively:

.combobox-wrapper {
  position: relative;
  font-family: system-ui, sans-serif;
  max-width: 320px;
}

.combobox-label {
  display: block;
  font-weight: 600;
  margin-bottom: 0.5rem;
  font-size: 0.875rem;
}

.combobox-input-container input {
  width: 100%;
  padding: 0.75rem;
  font-size: 1rem;
  border: 1px solid #ccc;
  border-radius: 6px;
  outline: none;
}

.combobox-input-container input:focus {
  border-color: #2563eb;
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}

.combobox-listbox {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  max-height: 240px;
  overflow-y: auto;
  margin: 0;
  padding: 0.25rem 0;
  background: #ffffff;
  border: 1px solid #cbd5e1;
  border-radius: 6px;
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
  list-style: none;
  z-index: 50;
}

.combobox-option {
  padding: 0.5rem 0.75rem;
  cursor: pointer;
  font-size: 0.95rem;
}

.combobox-option.active {
  background-color: #eff6ff;
  color: #1d4ed8;
}

.combobox-option.selected {
  font-weight: bold;
}

.combobox-option.no-results {
  color: #64748b;
  cursor: default;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Conclusion and Next Steps

By combining React state hooks with WAI-ARIA 1.2 specifications like aria-activedescendant, robust keyboard event handlers, and polite live regions, we’ve built a truly inclusive combobox component.

When taking this component to production, consider adding:

  • Click-outside handlers to dismiss the dropdown when clicking elsewhere on the page.
  • Debounced async data fetching if your options list relies on an external API.
  • Virtualization (using libraries like @tanstack/react-virtual) if you plan to render thousands of options.

Build inclusively, test with screen readers like VoiceOver or NVDA, and happy coding!

More posts