All posts
30 Sep 2026

Select and Arrow: Building an Accessible Custom Dropdown in React from Scratch

Learn how to build a production-grade, accessible custom dropdown select component in React complete with ARIA listbox semantics, full keyboard navigation, and type-ahead search.

When building design systems, the native HTML <select> element is often the first thing developers want to replace. Native selects are notorious for being difficult to style consistently across browsers and operating systems. However, tossing out native elements means losing decades of built-in accessibility, screen reader support, keyboard navigation, and mobile OS optimizations.

To build a custom dropdown select component that doesn’t alienate keyboard users or screen reader tech, we need to implement the WAI-ARIA Listbox Pattern.

In this guide, we will build a production-ready, fully accessible custom select component in React and TypeScript featuring:

  • Complete ARIA attributes (role="combobox", role="listbox", role="option", etc.)
  • Robust keyboard navigation (Arrow keys, Home, End, Enter, Escape, Space)
  • Type-ahead search (jumping to options by typing letters)
  • Outside click and focus management

The Architecture of an Accessible Select

A custom select consists of two primary interactive parts:

  1. The Trigger (Combobox): A button-like element that displays the currently selected value and toggles the dropdown menu. It requires aria-haspopup="listbox", aria-expanded, and aria-controls.
  2. The Dropdown (Listbox): A floating container holding the selectable items. It requires role="listbox" and contains options with role="option".

Let’s start by defining our TypeScript interfaces.

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

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

Step 1: Setting Up State and Refs

Our component needs to track whether the menu is open, which option is currently highlighted (focused via keyboard, distinct from selected), and references to DOM nodes for focus management.

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

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

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

  // ... hook implementations to follow
};

Step 2: Implementing WAI-ARIA Attributes

Screen readers rely entirely on correct ARIA attributes to understand state changes. Let’s wire up the trigger and the listbox container correctly.

// Inside CustomSelect return statement

return (
  <div className="relative w-72">
    {/* Visual or sr-only label */}
    <label id={`${idPrefix}-label`} className="block text-sm font-medium text-gray-700 mb-1">
      {label}
    </label>

    {/* Combobox Trigger */}>
    <button
      ref={triggerRef}
      type="button"
      role="combobox"
      aria-haspopup="listbox"
      aria-expanded={isOpen}
      aria-controls={`${idPrefix}-listbox`}
      aria-labelledby={`${idPrefix}-label ${idPrefix}-trigger`}
      id={`${idPrefix}-trigger`}
      disabled={disabled}
      onClick={() => setIsOpen(!isOpen)}
      className="w-full flex items-center justify-between px-3 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 disabled:cursor-not-allowed"
    >
      <span id={`${idPrefix}-value">
        {selectedOption ? selectedOption.label : <span className="text-gray-400">{placeholder}</span>}
      </span>
      <svg className="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
        <path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
      </svg>
    </button>

    {/* Listbox Dropdown */}
    {isOpen && (
      <ul
        ref={listboxRef}
        id={`${idPrefix}-listbox`}
        role="listbox"
        aria-labelledby={`${idPrefix}-label`}
        tabIndex={-1}
        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 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);
                  setIsOpen(false);
                  triggerRef.current?.focus();
                }
              }}
              className={`cursor-default select-none relative py-2 pl-3 pr-9 ${
                isActive ? 'bg-blue-600 text-white' : 'text-gray-900'
              } ${option.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-blue-50 hover:text-blue-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-blue-600'}`}>
                  ✓
                </span>
              )}
            </li>
          );
        })}
      </ul>
    )}
  </div>
);

Step 3: Comprehensive Keyboard Navigation

Keyboard accessibility is where most custom dropdowns fail. Users expect native-like interactions:

  • Enter / Space: Opens the menu or selects the active option.
  • Arrow Down / Arrow Up: Opens the menu and cycles through options.
  • Home / End: Jumps to the first or last option.
  • Escape: Closes the menu without selecting.
  • Type-ahead: Typing characters jumps focus to matching options.

Let’s implement a robust keyboard event handler for the trigger and listbox:

const handleKeyDown = (e: React.KeyboardEvent) => {
  if (disabled) return;

  switch (e.key) {
    case 'Enter':
    case ' ': 
      e.preventDefault();
      if (!isOpen) {
        setIsOpen(true);
        // Highlight current selection or first item
        const selectedIdx = options.findIndex(o => o.value === value);
        setActiveIndex(selectedIdx >= 0 ? selectedIdx : 0);
      } else if (activeIndex >= 0 && !options[activeIndex].disabled) {
        onChange(options[activeIndex].value);
        setIsOpen(false);
        triggerRef.current?.focus();
      }
      break;

    case 'ArrowDown':
      e.preventDefault();
      if (!isOpen) {
        setIsOpen(true);
        setActiveIndex(0);
      } else {
        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();
      if (!isOpen) {
        setIsOpen(true);
        setActiveIndex(options.length - 1);
      } else {
        setActiveIndex((prev) => {
          let next = prev - 1;
          while (next >= 0 && options[next].disabled) {
            next--;
          }
          return next >= 0 ? next : prev;
        });
      }
      break;

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

    case 'End':
      e.preventDefault();
      if (isOpen) {
        let lastValid = options.length - 1;
        while (lastValid >= 0 && options[lastValid].disabled) {
          lastValid--;
        }
        if (lastValid >= 0) setActiveIndex(lastValid);
      }
      break;

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

    default:
      // Handle type-ahead search
      if (isOpen && e.key.length === 1) {
        const char = e.key.toLowerCase();
        setSearchString(prev => {
          const newSearch = prev + char;
          const matchingIndex = options.findIndex((opt, idx) => 
            idx >= activeIndex && opt.label.toLowerCase().startsWith(newSearch) && !opt.disabled
          );
          
          if (matchingIndex !== -1) {
            setActiveIndex(matchingIndex);
          } else {
            // Wrap around search
            const wrapIndex = options.findIndex(opt => 
              opt.label.toLowerCase().startsWith(newSearch) && !opt.disabled
            );
            if (wrapIndex !== -1) setActiveIndex(wrapIndex);
          }

          // Clear search buffer after 500ms of inactivity
          setTimeout(() => setSearchString(''), 500);
          return newSearch;
        });
      }
      break;
  }
};

Attach this onKeyDown handler to both the trigger button and the wrapper element so it catches events regardless of where focus sits.


Step 4: Managing Focus and Active Descendants

To ensure screen readers announce highlighted options correctly, we can use aria-activedescendant on our combobox or manage physical focus. Since our dropdown is a standard <ul>, using aria-activedescendant tied to the active list item ID provides an exceptionally smooth screen reader experience.

Update the trigger element to include aria-activedescendant:

<button
  // ... other props
  aria-activedescendant={isOpen && activeIndex >= 0 ? `${idPrefix}-option-${activeIndex}` : undefined}
  onKeyDown={handleKeyDown}
>

Handling Outside Clicks

When a user clicks outside the open select dropdown, it should gracefully close. We can implement a simple window event 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)) {
      setIsOpen(false);
    }
  };

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

Conclusion

Building a custom select component requires attention to detail, but adhering to the WAI-ARIA Listbox specification ensures your UI remains inclusive for screen reader users and power keyboard navigators alike.

By combining explicit ARIA roles (combobox, listbox, option), aria-activedescendant, robust arrow-key handlers, and type-ahead searching, you deliver an experience that matches—and often exceeds—native browser controls while maintaining complete design system freedom.

More posts