All posts
25 Sep 2026

Beyond the Native Select: Crafting an Accessible Combobox in React

A comprehensive, code-heavy walkthrough of building a fully accessible custom combobox and dropdown component in React featuring typeahead search, listbox popups, and strict keyboard navigation.

Beyond the Native Select: Crafting an Accessible Combobox in React

Tags: Accessibility, React, TypeScript, UI Components, Frontend

Native HTML <select> elements are fantastic for accessibility out of the box, but they are notoriously difficult to style consistently across operating systems and browsers. The moment your design system calls for custom icons, rich item renderers, embedded search fields, or grouped options, the native select falls short.

Rebuilding these controls with custom <div> and <input> elements unlocks design freedom, but it often sacrifices critical accessibility features. Screen readers get confused, keyboard users get trapped, and typeahead search breaks down.

In this deep dive, we will build a production-ready, fully accessible custom Combobox component in React using TypeScript. We will implement strict keyboard navigation, ARIA attributes, a listbox popup, and dynamic typeahead filtering.


Understanding the Anatomy of a 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 build a truly accessible combobox, our component must satisfy several core requirements:

  1. Semantic Roles: Using role="combobox", role="listbox", and role="option".
  2. State Management: Communicating states like aria-expanded, aria-activedescendant, and aria-selected to assistive technologies.
  3. Keyboard Navigation: Supporting ArrowDown, ArrowUp, Enter, Escape, and Home/End keys.
  4. Typeahead / Filtering: Narrowing down options as the user types.

TypeScript Interfaces and Component State

Let’s start by defining our types. We need a clear structure for our options and the props required by our React component.

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

export interface Option {
  id: string;
  label: string;
  value: string;
  disabled?: boolean;
}

export interface ComboboxProps {
  options: Option[];
  value: Option | null;
  onChange: (option: Option | null) => void;
  placeholder?: string;
  label: string;
  disabled?: boolean;
}

Next, let’s establish our component skeleton and internal state hooks:

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

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

  const uniqueId = useId();
  const listboxId = `combobox-listbox-${uniqueId}`;
  const labelId = `combobox-label-${uniqueId}`;

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

  // ... component implementation continues below
};

Handling Filtering and Selection

When a user types into the input field, we filter the options list. If the user clears the input or selects an item, we update the state accordingly.

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newQuery = e.target.value;
    setQuery(newQuery);
    if (!isOpen) setIsOpen(true);
    setActiveIndex(0);
    
    // If the user clears the input, clear the selected value
    if (newQuery === '') {
      onChange(null);
    }
  };

  const handleSelectOption = (option: Option) => {
    if (option.disabled) return;
    onChange(option);
    setQuery(option.label);
    setIsOpen(false);
    setActiveIndex(null);
    inputRef.current?.focus();
  };

Implementing Strict Keyboard Navigation

Keyboard accessibility is where most custom dropdowns fail. Screen reader users rely entirely on keyboard events to traverse options. We need to handle specific keystrokes carefully:

  • ArrowDown / ArrowUp: Opens the listbox (if closed) and moves the active highlight through the list.
  • Enter: Selects the currently highlighted option.
  • Escape: Closes the listbox and reverts or clears focus.
  • Home / End: Jumps to the first or last option.
  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]) {
          handleSelectOption(filteredOptions[activeIndex]);
        }
        break;

      case 'Escape':
        e.preventDefault();
        setIsOpen(false);
        setActiveIndex(null);
        if (value) setQuery(value.label);
        break;

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

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

      default:
        break;
    }
  };

Managing ARIA Attributes and Focus

To make our combobox understandable to screen readers, we tie the input and the listbox together using precise ARIA attributes.

  • role="combobox": Tells the browser this input controls a popup widget.
  • aria-expanded: Indicates whether the listbox popup is currently open.
  • aria-controls: Points to the ID of the <ul> listbox element.
  • aria-activedescendant: Points to the ID of the currently focused <li> option, allowing screen readers to announce active choices without moving physical DOM focus away from the input.
  // Ensure active option is scrolled into view when navigating via keyboard
  useEffect(() => {
    if (isOpen && activeIndex !== null && listboxRef.current) {
      const activeItem = listboxRef.current.children[activeIndex] as HTMLElement;
      if (activeItem) {
        activeItem.scrollIntoView({ block: 'nearest' });
      }
    }
  }, [activeIndex, isOpen]);

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

Assembling the Component JSX

Now we put all the pieces together inside our render method, connecting handlers, refs, and accessibility attributes.

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

      <div className="relative">
        <input
          ref={inputRef}
          type="text"
          role="combobox"
          aria-expanded={isOpen}
          aria-autocomplete="list"
          aria-controls={listboxId}
          aria-labelledby={labelId}
          aria-activedescendant={activeDescendantId}
          disabled={disabled}
          value={query}
          onChange={handleInputChange}
          onKeyDown={handleKeyDown}
          onClick={() => !isOpen && 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"
          >
            {filteredOptions.length === 0 ? (
              <li className="px-3 py-2 text-gray-500 text-sm select-none">
                No options found
              </li>
            ) : (
              filteredOptions.map((option, index) => {
                const isSelected = value?.id === option.id;
                const isActive = activeIndex === index;
                const optionId = `${listboxId}-option-${option.id}`;

                return (
                  <li
                    key={option.id}
                    id={optionId}
                    role="option"
                    aria-selected={isSelected}
                    aria-disabled={option.disabled}
                    onClick={() => handleSelectOption(option)}
                    onMouseEnter={() => setActiveIndex(index)}
                    className={`px-3 py-2 text-sm cursor-pointer select-none ${
                      isActive ? 'bg-blue-600 text-white' : 'text-gray-900'
                    } ${option.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
                  >
                    {option.label}
                  </li>
                );
              })
            )}
          </ul>
        )}
      </div>
    </div>
  );
};

Handling Outside Clicks

One final detail is ensuring the dropdown closes when clicking outside of the component boundary. We can implement a simple document-level click listener using a React ref.

  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
        setIsOpen(false);
        if (value) setQuery(value.label);
      }
    };

    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, [value]);

Wrap the outermost div with ref={containerRef}, and your outside-click handling is complete!

Pro-Tip: Always test your components with a screen reader like VoiceOver (macOS) or NVDA (Windows) while navigating strictly via the keyboard. If you can complete a selection loop without touching your mouse, your accessibility implementation is on the right track.


Conclusion

Building custom form controls in React gives you boundless creative freedom, but it comes with the heavy responsibility of maintaining accessibility standards. By pairing proper WAI-ARIA roles (combobox, listbox, option) with robust keyboard handlers (aria-activedescendant and arrow key navigation), you can craft delightful, pixel-perfect UI components that leave no user behind.

More posts