All posts
14 Oct 2026

Building an Accessible Custom Select Menu in React: ARIA Listbox Patterns and Keyboard Navigation

A deep-dive into crafting a production-ready, highly accessible custom select component in React and TypeScript using proper ARIA listbox roles, robust keyboard navigation, type-ahead filtering, and focus management.

Building an Accessible Custom Select Menu in React: ARIA Listbox Patterns and Keyboard Navigation

Native HTML <select> elements are robust, universally accessible, and trivial to implement. However, their styling limitations are legendary. When design systems call for fully custom icons, grouped options, avatars, or complex animations, developers are forced to build custom dropdowns from scratch.

Unfortunately, custom select menus are among the most frequently broken UI components on the web. Without meticulous attention to semantics and keyboard interactions, they alienate screen reader users and keyboard power-users alike.

In this technical deep-dive, we will build a production-grade, fully accessible custom select component in React and TypeScript. We will implement the W3C ARIA Authoring Practices Guide (APG) Listbox pattern, explore keyboard navigation strategies, handle type-ahead filtering, and manage focus robustly.


The Anatomy of an Accessible Select

Before writing code, we must understand what makes a select menu accessible. According to the W3C APG Listbox pattern, an accessible select consists of:

  1. A Trigger Element: Typically a button that toggles the dropdown, labeled properly, and indicating the current selection and expanded state.
  2. A Listbox Popup: A container with role="listbox" that holds the options.
  3. Option Elements: Individual items with role="option" that communicate their selected and focused states.

Crucially, we must choose between two distinct focus management strategies: Roving Tabindex vs. aria-activedescendant.

Roving Tabindex vs. aria-activedescendant

  • Roving Tabindex: The DOM focus moves directly to the active option element (tabIndex={0} for the active item, -1 for all others). This is generally easier to implement in React because standard browser focus management handles scrolling and screen reader announcements automatically.
  • aria-activedescendant: The focus remains on the trigger or the listbox container, and the aria-activedescendant attribute points to the DOM ID of the currently active option.

For most standard select menus, Roving Tabindex offers superior screen reader compatibility and simpler maintenance in React. We will use a modified roving approach where focus stays on the listbox container while managing an internal active index.


Step 1: Defining Types and Interfaces

Let us start by defining our TypeScript interfaces to ensure strict typing for our options, props, and internal state.

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

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

export interface CustomSelectProps {
  options: Option[];
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  label: string;
  disabled?: boolean;
  className?: string;
}

Step 2: Component Architecture and State Management

Our component needs to track several pieces of state: whether the menu is open, the currently highlighted option index (for keyboard navigation), and string buffers for type-ahead searching.

export const CustomSelect: React.FC<CustomSelectProps> = ({
  options,
  value,
  onChange,
  placeholder = 'Select an option...',
  label,
  disabled = false,
  className = '',
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState<number>(-1);
  const [searchString, setSearchString] = useState<string>('');
  
  const idPrefix = useId();
  const triggerRef = useRef<HTMLButtonElement>(null);
  const listboxRef = useRef<HTMLUListElement>(null);
  const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);

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

  // Find initial active index when opening
  const handleOpen = () => {
    if (disabled) return;
    setIsOpen(true);
    const currentIndex = options.findIndex((opt) => opt.value === value);
    setActiveIndex(currentIndex >= 0 ? currentIndex : 0);
  };

  const handleClose = () => {
    setIsOpen(false);
    setActiveIndex(-1);
    triggerRef.current?.focus();
  };

  // ... rest of implementation
};

Step 3: Implementing Robust Keyboard Navigation

The W3C APG specifies exact keyboard interactions for a listbox:

  • Enter or Space: Opens the listbox when closed. When open, selects the currently focused option and closes the listbox.
  • Escape: Closes the listbox without selecting a new option.
  • ArrowDown / ArrowUp: Moves focus/highlight to the next or previous option, wrapping or stopping at boundaries.
  • Home / End: Jumps to the first or last option.
  • Character Keys: Implements type-ahead search to jump to options matching typed characters.

Here is how we implement this inside our onKeyDown handler for the trigger and listbox container:

const handleKeyDown = (e: KeyboardEvent) => {
  if (!isOpen) {
    if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(e.key)) {
      e.preventDefault();
      handleOpen();
    }
    return;
  }

  switch (e.key) {
    case 'Escape':
      e.preventDefault();
      handleClose();
      break;

    case 'Enter':
    case ' ':
      e.preventDefault();
      if (activeIndex >= 0 && !options[activeIndex].disabled) {
        onChange(options[activeIndex].value);
        handleClose();
      }
      break;

    case 'ArrowDown':
      e.preventDefault();
      setActiveIndex((prev) => {
        let next = prev + 1;
        while (next < options.length && options[next].disabled) {
          next++;
        }
        return next < options.length ? next : prev;
      });
      break;

    case 'ArrowUp':
      e.preventDefault();
      setActiveIndex((prev) => {
        let next = prev - 1;
        while (next >= 0 && options[next].disabled) {
          next--;
        }
        return next >= 0 ? next : prev;
      });
      break;

    case 'Home':
      e.preventDefault();
      const firstValid = options.findIndex((o) => !o.disabled);
      if (firstValid >= 0) setActiveIndex(firstValid);
      break;

    case 'End':
      e.preventDefault();
      const lastValid = options.map((o, i) => (!o.disabled ? i : -1)).filter(i => i >= 0).pop();
      if (lastValid !== undefined) setActiveIndex(lastValid);
      break;

    default:
      // Handle type-ahead
      if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
        handleTypeAhead(e.key);
      }
      break;
  }
};

Step 4: Adding Type-Ahead Filtering

Type-ahead functionality allows users to quickly jump to an option by typing its starting letters. We accumulate keystrokes within a short time window (e.g., 500ms) and find the first matching option.

const handleTypeAhead = (char: string) => {
  if (searchTimeoutRef.current) {
    clearTimeout(searchTimeoutRef.current);
  }

  const newSearch = searchString + char;
  setSearchString(newSearch);

  searchTimeoutRef.current = setTimeout(() => {
    setSearchString('');
  }, 500);

  const matchIndex = options.findIndex((opt,
    // Search starting from current active index + 1, wrapping around
    i) => {
    const index = (activeIndex + 1 + i) % options.length;
    return (
      !options[index].disabled &&
      options[index].label.toLowerCase().startsWith(newSearch.toLowerCase())
    );
  });

  if (matchIndex !== -1) {
    setActiveIndex(matchIndex);
  }
};

Step 5: Connecting ARIA Attributes and Rendering

Now, let’s assemble the complete JSX return statement, wiring up all necessary ARIA attributes (aria-expanded, aria-haspopup, aria-labelledby, aria-activedescendant, role="listbox", and role="option").

const listboxId = `${idPrefix}-listbox`;
const labelId = `${idPrefix}-label`;
const activeOptionId = activeIndex >= 0 ? `${idPrefix}-option-${activeIndex}` : undefined;

return (
  <div className={`relative inline-block w-64 ${className}`}>
    <span id={labelId} className="block text-sm font-medium text-slate-700 mb-1">
      {label}
    </span>

    <button
      ref={triggerRef}
      type="button"
      disabled={disabled}
      aria-haspopup="listbox"
      aria-expanded={isOpen}
      aria-labelledby={`${labelId} ${idPrefix}-trigger-value`}
      id={`${idPrefix}-trigger`}
      onClick={() => (isOpen ? handleClose() : handleOpen())}
      onKeyDown={handleKeyDown}
      className={`w-full flex items-center justify-between px-3 py-2 bg-white border ${
        disabled ? 'bg-slate-100 text-slate-400 cursor-not-allowed' : 'border-slate-300 hover:border-slate-400'
      } rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500`}
    >
      <span id={`${idPrefix}-trigger-value"`} className="truncate">
        {selectedOption ? selectedOption.label : placeholder}
      </span>
      <svg
        className={`w-5 h-5 text-slate-400 transition-transform ${isOpen ? 'transform rotate-180' : ''}`}
        fill="none"
        stroke="currentColor"
        viewBox="0 0 24 24"
        aria-hidden="true"
      >
        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
      </svg>
    </button>

    {isOpen && (
      <ul
        ref={listboxRef}
        role="listbox"
        id={listboxId}
        aria-labelledby={labelId}
        aria-activedescendant={activeOptionId}
        tabIndex={-1}
        className="absolute z-10 w-full mt-1 bg-white border border-slate-300 rounded-md shadow-lg max-h-60 overflow-auto focus:outline-none"
      >
        {options.map((option, index) => {
          const isSelected = option.value === value;
          const isActive = index === activeIndex;
          const optionId = `${idPrefix}-option-${index}`;

          return (
            <li
              key={option.value}
              id={optionId}
              role="option"
              aria-selected={isSelected}
              aria-disabled={option.disabled}
              onClick={() => {
                if (!option.disabled) {
                  onChange(option.value);
                  handleClose();
                }
              }}
              onMouseEnter={() => !option.disabled && setActiveIndex(index)}
              className={`cursor-pointer select-none relative py-2 pl-3 pr-9 ${
                isActive ? 'bg-indigo-600 text-white' : 'text-slate-900'
              } ${option.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-indigo-50 hover:text-slate-900'}`}
            >
              <span className={`block truncate ${isSelected ? 'font-semibold' : 'font-normal'}`}>
                {option.label}
              </span>

              {isSelected && (
                <span
                  className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
                    isActive ? 'text-white' : 'text-indigo-600'
                  }`}
                >
                  <svg className="w-5 h-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                    <path
                      fillRule="evenodd"
                      d="M16.707 5.293a1 `1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
                      clipRule="N"
                    />
                  </svg>
                </span>
              )}
            </li>
          );
        })}
      </ul>
    )}
  </div>
);
};

Step 6: Handling External Clicks and Scroll Alignment

To ensure our dropdown behaves like a native select menu, two final pieces of polish are required:

  1. Click Outside to Close: Clicking anywhere outside the component should dismiss the menu.
  2. Scroll Into View: When navigating with arrow keys, the active item must automatically scroll into view if it overflows the listbox container.
// Click outside listener
useEffect(() => {
  const handleClickOutside = (event: MouseEvent) => {
    if (
      triggerRef.current &&
      !triggerRef.current.contains(event.target as Node) &&
      listboxRef.current &&
      !listboxRef.current.contains(event.target as Node)
    ) {
      if (isOpen) handleClose();
    }
  };

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

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

Conclusion

Building custom UI components in React requires looking beyond visual aesthetics and prioritizing the invisible UX of accessibility and keyboard navigation. By strictly following the W3C ARIA Listbox pattern, utilizing aria-activedescendant, implementing robust keyboard handlers, and supporting type-ahead search, we have created a custom select component that feels indistinguishable from a native element while remaining fully brandable.

Always test your components with screen readers (like VoiceOver or NVDA) and keyboard-only navigation to ensure all users enjoy a seamless experience.

More posts