All posts
2 Oct 2026

Arrow Keys, Type-Ahead, and ARIA: Building an Accessible Custom Select in React

Learn how to build a production-ready, fully accessible custom select and combobox component in React and TypeScript featuring roving tabindex, arrow navigation, type-ahead search, and robust screen reader support.

Arrow Keys, Type-Ahead, and ARIA: Building an Accessible Custom Select in React

When building design systems in React, native <select> elements are often abandoned in favor of custom UI components. While native elements handle accessibility, keyboard navigation, and mobile viewports out of the box, they are notoriously difficult to style consistently across browsers.

However, building a custom select from scratch comes with a major trap: breaking accessibility. A standard <div> styled to look like a dropdown is invisible to screen readers and inaccessible to keyboard-only users.

In this walkthrough, we will build a production-ready, fully accessible custom select component using React and TypeScript. We will cover ARIA authoring practices, roving tabindex, arrow key navigation, type-ahead search, and focus management.


The Anatomy of an Accessible Select

According to the WAI-ARIA Authoring Practices Guide (APG), a custom select or combobox requires a strict pattern of roles, states, and properties. We need:

  1. A Trigger Button: Acts as the launcher. It requires aria-haspopup="listbox", aria-expanded, and aria-controls pointing to the dropdown list ID.
  2. A Listbox Container: Holds the options and must have role="listbox".
  3. Option Elements: Each item must have role="option" and a dynamic aria-selected state.
  4. Focus Management: Focus must move intuitively when using ArrowUp, ArrowDown, Home, End, and typing characters for type-ahead search.

Setting Up the TypeScript Interfaces

Let’s start by defining our types. We need a flexible option structure and props interface.

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

export interface SelectOption {
  label: string;
  value: string | number;
  disabled?: boolean;
}

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

Building the Component Skeleton

We will manage three primary pieces of state: whether the dropdown is open (isOpen), the index of the currently highlighted option (highlightedIndex), and refs to manage DOM focus.

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

  const buttonRef = useRef<HTMLButtonElement>(null);
  const listboxRef = useRef<HTMLUListElement>(null);
  
  const idPrefix = useId();
  const listboxId = `${idPrefix}-listbox`;
  const labelId = `${idPrefix}-label`;

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

  // Component implementation continues below...

Handling Keyboard Navigation

Keyboard interactions are what separate a mediocre dropdown from an accessible one. Users expect:

  • Enter or Space: Opens the listbox or selects the highlighted option.
  • ArrowDown / ArrowUp: Opens the listbox and moves focus/highlight down or up through non-disabled options.
  • Escape: Closes the listbox without selecting, returning focus to the trigger.
  • Home / End: Jumps to the first or last available option.
  • Type-Ahead: Typing letters jumps directly to matching options.

Let’s implement the handleKeyDown function for the trigger and listbox:

  const handleTriggerKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
    if (disabled) return;

    switch (e.key) {
      case 'Enter':
      case ' ':'
      case 'ArrowDown':
      case 'ArrowUp':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
          // Highlight currently selected item, or first item
          const selectedIdx = options.findIndex((opt) => opt.value === value);
          setHighlightedIndex(selectedIdx >= 0 ? selectedIdx : 0);
        }
        break;
      case 'Escape':
        if (isOpen) {
          e.preventDefault();
          setIsOpen(false);
        }
        break;
    }
  };

Listbox and Type-Ahead Logic

When the listbox is open, keystrokes must target the options list. We also want to implement type-ahead search so users can type the first few letters of an option to jump to it.

  const typeAheadBuffer = useRef<string>('');
  const typeAheadTimeout = useRef<NodeJS.Timeout | null>(null);

  const handleTypeAhead = (char: string) => {
    if (typeAheadTimeout.current) clearTimeout(typeAheadTimeout.current);
    
    typeAheadBuffer.current += char.toLowerCase();
    
    const matchingIndex = options.findIndex((opt) =>
      opt.label.toLowerCase().startsWith(typeAheadBuffer.current)
    );

    if (matchingIndex !== -1) {
      setHighlightedIndex(matchingIndex);
    }

    typeAheadTimeout.current = setTimeout(() => {
      typeAheadBuffer.current = '';
    }, 500);
  };

  const handleListKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (!isOpen) return;

    switch (e.key) {
      case 'ArrowDown': {
        e.preventDefault();
        setHighlightedIndex((prev) => {
          let next = prev + 1;
          while (next < options.length && options[next].disabled) {
            next++;
          }
          return next < options.length ? next : prev;
        });
        break;
      }
      case 'ArrowUp': {
        e.preventDefault();
        setHighlightedIndex((prev) => {
          let next = prev - 1;
          while (next >= 0 && options[next].disabled) {
            next--;
          }
          return next >= 0 ? next : prev;
        });
        break;
      }
      case 'Home':
        e.preventDefault();
        setHighlightedIndex(options.findIndex((o) => !o.disabled));
        break;
      case 'End':
        e.preventDefault();
        for (let i = options.length - 1; i >= 0; i--) {
          if (!options[i].disabled) {
            setHighlightedIndex(i);
            break;
          }
        }
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (highlightedIndex >= 0 && !options[highlightedIndex].disabled) {
          onChange(options[highlightedIndex].value);
          setIsOpen(false);
          buttonRef.current?.focus();
        }
        break;
      case 'Escape':
        e.preventDefault();
        setIsOpen(false);
        buttonRef.current?.focus();
        break;
      default:
        if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
          handleTypeAhead(e.key);
        }
        break;
    }
  };

Managing Focus and Roving Tabindex

Screen readers rely heavily on aria-activedescendant or DOM focus movement. Using aria-activedescendant on the listbox pointing to the ID of the currently highlighted option is the cleanest pattern because it keeps focus on the container while communicating the active child to assistive tech.

Let’s ensure our DOM reflects this state correctly.

  // Scroll highlighted option into view when index changes
  useEffect(() => {
    if (isOpen && highlightedIndex >= 0) {
      const optionElement = document.getElementById(`${idPrefix}-option-${highlightedIndex}`);
      optionElement?.scrollIntoView({ block: 'nearest' });
    }
  }, [highlightedIndex, isOpen, idPrefix]);

  // Return focus to trigger when closing
  useEffect(() => {
    if (!isOpen) {
      setHighlightedIndex(-1);
    }
  }, [isOpen]);

Putting It Together: The JSX Structure

Now we render the components with all mandatory ARIA attributes in place.

  return (
    <div className="relative w-72">
      <label id={labelId} className="block text-sm font-medium text-gray-700 mb-1">
        {label}
      </label>
      
      <div className="relative">
        {/* Trigger Button */}
        <button
          ref={buttonRef}
          type="button"
          role="combobox"
          aria-expanded={isOpen}
          aria-haspopup="listbox"
          aria-controls={listboxId}
          aria-labelledby={labelId}
          disabled={disabled}
          onClick={() => setIsOpen((prev) => !prev)}
          onKeyDown={handleTriggerKeyDown}
          className="w-full flex items-center justify-between px-4 py-2 bg-white border border-gray-300 rounded-md shadow-sm text-left focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
        >
          <span className={selectedOption ? 'text-gray-900' : 'text-gray-400'}>
            {selectedOption ? selectedOption.label : placeholder}
          </span>
          <svg
            className={`w-5 h-5 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
            fill="none"
            stroke="currentColor"
            viewBox="0 0 24 24"
          >
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
          </svg>
        </button>

        {/* Dropdown Listbox */}
        {isOpen && (
          <ul
            ref={listboxRef}
            id={listboxId}
            role="listbox"
            aria-labelledby={labelId}
            aria-activedescendant={
              highlightedIndex >= 0 ? `${idPrefix}-option-${highlightedIndex}` : undefined
            }
            tabIndex={-1}
            onKeyDown={handleListKeyDown}
            className="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm"
          >
            {options.map((option, index) => {
              const isSelected = option.value === value;
              const isHighlighted = index === highlightedIndex;

              return (
                <li
                  key={option.value}
                  id={`${idPrefix}-option-${index}`}
                  role="option"
                  aria-selected={isSelected}
                  aria-disabled={option.disabled}
                  onClick={() => {
                    if (!option.disabled) {
                      onChange(option.value);
                      setIsOpen(false);
                      buttonRef.current?.focus();
                    }
                  }}
                  onMouseEnter={() => !option.disabled && setHighlightedIndex(index)}
                  className={`cursor-default select-none relative py-2 pl-3 pr-9 ${
                    isHighlighted ? 'bg-blue-600 text-white' : 'text-gray-900'
                  } ${option.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
                >
                  <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 ${
                        isHighlighted ? 'text-white' : 'text-blue-600'
                      }`}
                    >
                      <svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
                        <path
                          fillRule="evenodd"
                          d="M16.707 5.293a1.e 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="veenodd"
                        />
                      </svg>
                    </span>
                  )}
                </li>
              );
            })}
          </ul>
        )}
      </div>
    </div>
  );
};

Testing Your Implementation

Before shipping your custom select component, perform these manual accessibility sanity checks:

  1. The Screen Reader Test: Turn on VoiceOver (macOS) or NVDA (Windows). Focus the select trigger. Does it announce the label, state (collapsed), and role (combobox)? Press Space/Enter. Does it announce the expanded listbox and the currently focused option via aria-activedescendant?
  2. The Keyboard-Only Test: Unplug your mouse. Can you open the dropdown using ArrowDown, navigate options, select an item with Enter, and close with Escape without losing focus?
  3. Type-Ahead Check: Open the dropdown and type letters rapidly. Does the focus jump straight to matching items?

Conclusion

Building custom components in React offers boundless design freedom, but it shifts the responsibility of accessibility entirely onto your shoulders. By adhering strictly to the WAI-ARIA combobox patterns, implementing robust keyboard handlers, and managing focus cleanly using aria-activedescendant, you can create custom selects that delight sighted users while remaining fully navigable for assistive technology users.

More posts