All posts
21 Oct 2026

Building an Accessible Select Menu in React: Combobox Patterns and Keyboard Navigation

Learn how to build a fully accessible, WCAG-compliant custom select menu in React utilizing the WAI-ARIA combobox pattern, typeahead search, and complete keyboard navigation.

Building custom UI components in React often means sacrificing native browser accessibility for visual control. When it comes to <select> elements, native styling options are notoriously limited. However, replacing them with custom dropdowns without proper accessibility implementation can completely alienate keyboard users and screen reader users.

In this guide, we will build a production-ready, highly accessible custom select menu in React using TypeScript. We will adhere strictly to the WAI-ARIA 1.2 Combobox pattern, implement robust keyboard navigation, add typeahead search, and ensure screen readers announce state changes correctly.

Understanding the WAI-ARIA Combobox Pattern

The WAI-ARIA 1.2 specification defines a combobox as 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 build a screen-reader-friendly select menu, we need to manage two primary elements:

  1. The Combobox Trigger/Input: An element with role="combobox" that indicates its expanded state and controls the popup.
  2. The Listbox Popup: A container with role="listbox" containing options with role="option".

Core ARIA Attributes We Need:

  • aria-expanded: Communicates whether the dropdown list is open (true) or closed (false).
  • aria-haspopup: Set to "listbox" on the combobox trigger.
  • aria-controls: Links the combobox trigger to the id of the listbox popup.
  • aria-activedescendant: Points to the id of the currently highlighted option, allowing screen readers to track focus while physical focus remains on the input/trigger.
  • aria-selected: Indicates whether an option is currently selected.

Step 1: Component Architecture & Types

Let’s start by defining our TypeScript interfaces. We need a flexible option structure and props that support value changes and customization.

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

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

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

Step 2: State Management and Accessibility Hooks

Next, we will structure our React component. We need state for tracking whether the menu is open, the currently highlighted index (for keyboard navigation), and filtering/search query if we want typeahead capabilities.

export const AccessibleSelect: React.FC<AccessibleSelectProps> = ({
  options,
  value,
  onChange,
  placeholder = 'Select an option...',
  label,
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
  const [searchQuery, setSearchQuery] = useState('');

  const comboboxId = useId();
  const listboxId = useId();
  const labelId = useId();

  const triggerRef = useRef<HTMLButtonElement>(null);
  const listboxRef = useRef<HTMLUListElement>(null);

  const selectedOption = options.find((opt) => opt.value === value);

  // Filter options based on typeahead query
  const filteredOptions = options.filter((opt) =>
    opt.label.toLowerCase().includes(searchQuery.toLowerCase())
  );

  // Close listbox when clicking outside
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (
        triggerRef.current &&
        !triggerRef.current.contains(event.target as Node) &&
        listboxRef.current &&
        !listboxRef.current.contains(event.target as Node)
      ) {
        setIsOpen(false);
        setSearchQuery('');
      }
    };
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

  // Scroll highlighted option into view
  useEffect(() => {
    if (isOpen && highlightedIndex >= 0 && listboxRef.current) {
      const optionElement = listboxRef.current.children[highlightedIndex] as HTMLElement;
      if (optionElement) {
        optionElement.scrollIntoView({ block: 'nearest' });
      }
    }
  }, [highlightedIndex, isOpen]);

  // ... remaining logic
};

Step 3: Implementing Keyboard Navigation

Keyboard accessibility is where most custom select menus fail. Users expect standard behaviors:

  • Enter or Space: Opens the menu, or selects the highlighted option.
  • ArrowDown / ArrowUp: Opens the menu and navigates through options.
  • Escape: Closes the menu without selecting.
  • Home / End: Jumps to the first or last option.
  • Printable characters: Initiates typeahead searching.
  const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
    switch (e.key) {
      case 'Enter':
      case ' ': 
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
          const selectedIndex = filteredOptions.findIndex((opt) => opt.value === value);
          setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : 0);
        } else if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
          selectOption(filteredOptions[highlightedIndex]);
        }
        break;

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

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

      case 'Escape':
        if (isOpen) {
          e.preventDefault();
          setIsOpen(false);
          setSearchQuery('');
          triggerRef.current?.focus();
        }
        break;

      case 'Home':
        if (isOpen) {
          e.preventDefault();
          setHighlightedIndex(0);
        }
        break;

      case 'End':
        if (isOpen) {
          e.preventDefault();
          setHighlightedIndex(filteredOptions.length - 1);
        }
        break;

      default:
        // Typeahead search handling for alphanumeric characters
        if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
          if (!isOpen) setIsOpen(true);
          const newQuery = searchQuery + e.key;
          setSearchQuery(newQuery);
          
          const matchedIndex = filteredOptions.findIndex((opt) =>
            opt.label.toLowerCase().startsWith(newQuery.toLowerCase())
          );
          if (matchedIndex >= 0) {
            setHighlightedIndex(matchedIndex);
          }
        }
        break;
    }
  };

  const selectOption = (option: Option) => {
    if (option.disabled) return;
    onChange(option.value);
    setIsOpen(false);
    setSearchQuery('');
    triggerRef.current?.focus();
  };

Step 4: Putting It Together with JSX and ARIA attributes

Now, let’s render the component structure, ensuring that aria-activedescendant points dynamically to the active option’s DOM node ID.

  const activeDescendantId =
    isOpen && highlightedIndex >= 0
      ? `${comboboxId}-option-${highlightedIndex}`
      : undefined;

  return (
    <div className="accessible-select-container">
      <label id={labelId} className="select-label">
        {label}
      </label>
      
      <div className="combobox-wrapper">
        <button
          ref={triggerRef}
          id={comboboxId}
          type="button"
          role="combobox"
          aria-expanded={isOpen}
          aria-haspopup="listbox"
          aria-controls={listboxId}
          aria-labelledby={labelId}
          aria-activedescendant={activeDescendantId}
          onClick={() => {
            setIsOpen(!isOpen);
            if (!isOpen) {
              const selectedIndex = filteredOptions.findIndex((opt) => opt.value === value);
              setHighlightedIndex(selectedIndex >= 0 ? selectedIndex : 0);
            }
          }}
          onKeyDown={handleKeyDown}
          className="combobox-trigger"
        >
          <span className="selected-value">
            {selectedOption ? selectedOption.label : placeholder}
          </span>
          <span className="chevron-icon" aria-hidden="true">
            ▼
          </span>
        </button>

        {isOpen && (
          <ul
            ref={listboxRef}
            id={listboxId}
            role="listbox"
            aria-labelledby={labelId}
            className="listbox-popup"
          >
            {filteredOptions.length === 0 ? (
              <li className="no-results" role="presentation">
                No options found
              </li>
            ) : (
              filteredOptions.map((option, index) => {
                const isSelected = option.value === value;
                const isHighlighted = index === highlightedIndex;

                return (
                  <li
                    key={option.value}
                    id={`${comboboxId}-option-${index}`}
                    role="option"
                    aria-selected={isSelected}
                    aria-disabled={option.disabled}
                    className={`option-item ${
                      isHighlighted ? 'highlighted' : ''
                    } ${isSelected ? 'selected' : ''} ${
                      option.disabled ? 'disabled' : ''
                    }`}
                    onClick={() => selectOption(option)}
                    onMouseEnter={() => setHighlightedIndex(index)}
                  >
                    {option.label}
                  </li>
                );
              })
            )}
          </ul>
        )}
      </div>
    </div>
  );
};

Step 5: Essential CSS for Visual States

Accessibility is not just about screen readers; sighted keyboard users require clear visual cues for focus and selection. Here is baseline CSS to support our interactive states:

.accessible-select-container {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  font-family: sans-serif;
  width: 300px;
  position: relative;
}

.combobox-trigger {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem 1rem;
  border: 2px solid #ccc;
  border-radius: 6px;
  background: #fff;
  cursor: pointer;
  font-size: 1rem;
  text-align: left;
}

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

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

.option-item {
  padding: 0.75rem 1rem;
  cursor: pointer;
  font-size: 1rem;
}

.option-item.highlighted {
  background-color: #eff6ff;
  color: #1d4ed8;
}

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

.option-item.disabled {
  color: #94a3b8;
  cursor: not-allowed;
}

.no-results {
  padding: 0.75rem 1rem;
  color: #64748b;
  font-style: italic;
}

Pro Tip: Always test your custom components with real assistive technologies. Screen readers like VoiceOver (macOS/iOS) and NVDA (Windows) will verify that your aria-activedescendant and combobox relationships announce options accurately as you navigate with arrow keys.

Conclusion

By adhering to the WAI-ARIA 1.2 combobox specification, combining robust keyboard handlers (ArrowDown, ArrowUp, Enter, Escape), and managing dynamic attributes like aria-activedescendant, you ensure that every user—regardless of how they navigate your application—has a seamless experience. Custom design systems no longer need to compromise accessibility for aesthetic excellence.

More posts