All posts
10 Oct 2026

Dropdowns Done Right: Building an Accessible Menu in React

Learn how to build a production-ready, fully accessible custom React dropdown menu from scratch using TypeScript, complete with ARIA attributes, robust keyboard navigation, and focus management.

Dropdowns Done Right: Building an Accessible Menu in React

Dropdown menus are a staple of modern web applications. Yet, if you inspect custom dropdowns across the web, an alarming number of them fail basic accessibility audits. They trap screen readers, ignore keyboard shortcuts, or lose focus management the moment a user presses the Escape key.

Building an accessible dropdown menu—often referred to in the WAI-ARIA authoring practices as a Menu Button pattern—requires careful attention to semantics, keyboard interactions, and focus states.

In this practical walkthrough, we’ll build a robust, fully accessible dropdown menu component in React and TypeScript. We will cover semantic ARIA attributes, compare navigation strategies like roving tabindex versus aria-activedescendant, and implement bulletproof keyboard handling.


The Anatomy of an Accessible Dropdown

Before writing any code, let’s establish what makes a dropdown accessible:

  1. Semantic Trigger: The button that toggles the menu must communicate its state (aria-expanded) and control the menu (aria-controls).
  2. Menu Semantics: The container holding the options must have role="menu".
  3. Item Semantics: Each interactive option must have role="menuitem".
  4. Focus Management: When the menu opens, focus should move directly to the first item (or trigger). When it closes, focus must return to the trigger button.
  5. Keyboard Support: Users must be able to navigate using ArrowDown, ArrowUp, Home, End, Escape, Enter, and Space.

Setting Up the TypeScript Types and State

Let’s start by defining our component props and internal state structure. We’ll use a functional React component with TypeScript.

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

export interface DropdownItem {
  id: string;
  label: string;
  onClick: () => void;
  disabled?: boolean;
}

interface AccessibleDropdownProps {
  triggerLabel: string;
  items: DropdownItem[];
}

Strategy: Roving Tabindex vs. aria-activedescendant

When building list-based components, developers generally choose between two primary accessibility patterns for managing focus:

  1. aria-activedescendant: Focus remains on the container element (role="menu"), and an attribute points to the ID of the currently active child.
  2. Roving Tabindex: Only one item in the menu at a time has tabIndex={0}, while all other items have tabIndex={-1}. Focus is programmatically moved to the active DOM element.

While aria-activedescendant is powerful for complex virtualized lists, roving tabindex is generally easier to implement correctly in standard React components because it relies on native browser focus handling (element.focus()). We will use the roving tabindex approach for our implementation.


Building the Component

Here is the complete implementation of our accessible dropdown component:

export const AccessibleDropdown: React.FC<AccessibleDropdownProps> = ({
  triggerLabel,
  items,
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState<number>(0);

  const triggerRef = useRef<HTMLButtonElement>(null);
  const menuRef = useRef<HTMLDivElement>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);

  // Reset active index when menu opens
  const handleOpen = () => {
    setIsOpen(true);
    setActiveIndex(0);
  };

  // Close menu and return focus to trigger
  const handleClose = () => {
    setIsOpen(false);
    triggerRef.current?.focus();
  };

  // Focus the active item whenever activeIndex changes while open
  useEffect(() => {
    if (isOpen) {
      itemRefs.current[activeIndex]?.focus();
    }
  }, [activeIndex, isOpen]);

  // Handle outside clicks to close the menu
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (
        menuRef.current &&
        !menuRef.current.contains(event.target as Node) &&
        !triggerRef.current?.contains(event.target as Node)
      ) {
        setIsOpen(false);
      }
    };

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

  const handleKeyDown = (event: KeyboardEvent) => {
    switch (event.key) {
      case 'Escape':
        event.preventDefault();
        handleClose();
        break;
      case 'ArrowDown':
        event.preventDefault();
        setActiveIndex((prev) => (prev + 1) % items.length);
        break;
      case 'ArrowUp':
        event.preventDefault();
        setActiveIndex((prev) => (prev - 1 + items.length) % items.length);
        break;
      case 'Home':
        event.preventDefault();
        setActiveIndex(0);
        break;
      case 'End':
        event.preventDefault();
        setActiveIndex(items.length - 1);
        break;
      default:
        break;
    }
  };

  return (
    <div className="relative inline-block">
      <button
        ref={triggerRef}
        aria-haspopup="menu"
        aria-expanded={isOpen}
        aria-controls="accessible-menu"
        onClick={() => (isOpen ? handleClose() : handleOpen())}
        onKeyDown={(e) => {
          if (e.key === 'ArrowDown' && !isOpen) {
            e.preventDefault();
            handleOpen();
          }
        }}
        className="px-4 py-2 bg-blue-600 text-white rounded font-medium focus:outline-none focus:ring-2 focus:ring-blue-400"
      >
        {triggerLabel} ▾
      </button>

      {isOpen && (
        <div
          id="accessible-menu"
          ref={menuRef}
          role="menu"
          tabIndex={-1}
          onKeyDown={handleKeyDown}
          className="absolute left-0 mt-2 w-48 bg-white border border-gray-200 rounded shadow-lg py-1 z-10"
        >
          {items.map((item, index) => (
            <button
              key={item.id}
              ref={(el) => (itemRefs.current[index] = el)}
              role="menuitem"
              tabIndex={index === activeIndex ? 0 : -1}
              disabled={item.disabled}
              onClick={() => {
                item.onClick();
                handleClose();
              }}
              className={`w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none ${
                item.disabled ? 'opacity-50 cursor-not-allowed' : ''
              }`}
            >
              {item.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
};

Deep Dive: Key Accessibility Features Explained

1. ARIA Attributes on the Trigger

  • aria-haspopup="menu": Informs assistive technologies that activating this button will display a menu.
  • aria-expanded={isOpen}: Dynamically communicates whether the menu is currently visible (true) or hidden (false).
  • aria-controls="accessible-menu": Binds the button to the DOM ID of the menu container.

2. Managing Focus and the Roving Tabindex

Notice how each menu item sets its tabIndex dynamically:

tabIndex={index === activeIndex ? 0 : -1}

When the user presses ArrowDown or ArrowUp, we update activeIndex, which triggers our useEffect hook to call .focus() on the newly active DOM node. This ensures that keyboard-only users and screen reader users move sequentially through the menu items without jumping out of the document flow.

3. Escape-Key Handling and Focus Restoration

One of the most common accessibility bugs is the “focus drop.” When a user closes a dropdown via the Escape key, focus must be returned immediately to the element that originally triggered the menu:

case 'Escape':
  event.preventDefault();
  handleClose();
  break;

Coupled with triggerRef.current?.focus() inside handleClose(), this ensures seamless keyboard continuity.


Conclusion

Building accessible components in React doesn’t have to be overwhelming. By adhering to WAI-ARIA standards, thoughtfully managing focus states with refs, and anticipating keyboard interactions like arrow navigation and escape handling, you can deliver a polished experience for every user.

Always test your components with both a screen reader (such as VoiceOver or NVDA) and purely via keyboard navigation (Tab, Shift+Tab, Arrow keys, and Escape) to verify that your implementation holds up in real-world scenarios.

More posts