Tech Verse Logo
Enable dark mode
Building Accessible Dropdown Menus in React

Building Accessible Dropdown Menus in React

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

•5 min read

Most custom dropdown components fail basic accessibility audits. Developers often build a styled div with an onClick handler, wire up some local state for visibility, and call it a day. Screen readers ignore these custom elements entirely, and keyboard users get trapped or lost as soon as the menu opens. Real accessibility requires managing DOM focus explicitly, wiring up correct ARIA attributes, and intercepting specific keystrokes. It isn't just about passing tests. It's about letting everyone use your software.

You need to handle the button that triggers the menu, the container holding the items, and each individual list option. You also need to deal with pointer events versus keyboard events. If you're building complex forms alongside your menus, it's worth checking out Strict TypeScript Discriminated Unions for React Forms to keep your types airtight. If you want a refresher on fundamental component accessibility, check out Accessible React Components: Focus and ARIA before writing your markup.

Setting Up the Markup and ARIA Attributes

Screen readers rely on ARIA attributes to understand the relationship between the trigger button and the popup content. The button needs aria-haspopup="menu" to tell the user that activating it opens a menu. It also needs aria-expanded to reflect whether the menu is currently visible. Don't skip these attributes. They're critical.

The menu container must carry role="menu", and each child item requires role="menuitem". If you skip these roles, assistive tech treats your dropdown as a plain list of links or buttons, losing the semantic context of a command list. Screen readers switch into application mode when they encounter a menu role, which changes how keystrokes behave. If you omit the role, the user's screen reader won't read the item states properly, leaving them stranded without feedback.

You also need to manage unique ID attributes between the button and the menu container using aria-controls. This establishes an explicit programmatic link in the accessibility tree. Without this connection, a user jumping through landmark elements might lose track of where the popup content originated. Here's a complete component implementation that gets the basic wiring right:

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

export function AccessibleDropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const menuRef = useRef<HTMLDivElement>(null);

  const items = ['Profile', 'Settings', 'Sign Out'];

  useEffect(() => {
    if (isOpen && menuRef.current) {
      const menuItems = menuRef.current.querySelectorAll<HTMLElement>('[role="menuitem"]');
      if (menuItems[activeIndex]) {
        menuItems[activeIndex].focus();
      }
    }
  }, [activeIndex, isOpen]);

  const handleToggle = () => {
    setIsOpen(!isOpen);
    setActiveIndex(isOpen ? -1 : 0);
  };

  return (
    <div className="relative inline-block">
      <button
        ref={triggerRef}
        type="button"
        aria-haspopup="menu"
        aria-expanded={isOpen}
        aria-controls="dropdown-menu"
        onClick={handleToggle}
        className="px-4 py-2 bg-blue-600 text-white rounded"
      >
        Options
      </button>
      {isOpen && (
        <div
          id="dropdown-menu"
          ref={menuRef}
          role="menu"
          className="absolute left-0 mt-2 w-48 bg-white border rounded shadow-lg"
        >
          {items.map((item, index) => (
            <button
              key={item}
              role="menuitem"
              tabIndex={index === activeIndex ? 0 : -1}
              onClick={() => { 
                setIsOpen(false);
                triggerRef.current?.focus();
              }}
              className="w-full text-left px-4 py-2 hover:bg-gray-100"
            >
              {item}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

Managing Keyboard Navigation and Focus Traps

Click handlers alone don't make a component usable. Users expect to press the Down Arrow key to enter the menu and move through choices sequentially. If they press Escape, the menu must close and return focus to the trigger button. Without this loop, keyboard users have to tab backward through the entire page layout just to escape your component. That's a terrible user experience.

You also have to handle focus movement when the list items change. Setting tabIndex={-1} on non-active items keeps them out of the standard tab order while allowing programmatic focus via element.focus(). When the user presses ArrowDown, you increment the active index and let the effect hook pull focus to the newly selected item.

To prevent race conditions and layout bugs in complex forms, keep your focus state colocalized inside the dropdown wrapper. For deeper state architectures, you might look at patterns described in Advanced Component State Colocation in React to keep your event handlers clean and maintainable. Don't push everything up to the global root component. It'll cause cascading renders you don't want.

When implementing keyboard handlers, make sure you listen for keydown events rather than keypress or keyup. Keypress is deprecated, and keyup fires too late, making the interaction feel sluggish. You should also trap the tab key so that it doesn't leak out of the open menu into background content. If the user tabs while the menu is open, either close the menu or cycle focus back to the first item depending on your design requirements. Most standard menu patterns prefer closing the menu entirely on an outside tab event.

Handling Blur Events and Outside Clicks

One common production bug involves the menu failing to close when a user clicks outside its bounding box. Relying solely on a window click listener can cause issues with event propagation. Instead, check whether the newly focused element lies outside your container ref using the onBlur event paired with relatedTarget.

When focus moves outside both the trigger and the menu container, you close the dropdown immediately. If focus simply moves from the trigger into the first menu item, relatedTarget sits inside the menu tree, and you keep the component open. Here's how you handle blur containment correctly:

import React, { useState, useRef } from 'react';

export function RobustDropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);

  const handleBlur = (event: React.FocusEvent) => {
    if (
      containerRef.current &&
      event.relatedTarget &&
      containerRef.current.contains(event.relatedTarget as Node)
    ) {
      return;
    }
    setIsOpen(false);
  };

  return (
    <div ref={containerRef} onBlur={handleBlur} className="relative inline-block">
      <button
        type="button"
        aria-haspopup="menu"
        aria-expanded={isOpen}
        onClick={() => setIsOpen(!isOpen)}
        className="px-4 py-2 bg-gray-800 text-white rounded"
      >
        Actions
      </button>
      {isOpen && (
        <div role="menu" className="absolute left-0 mt-2 w-48 bg-white border rounded shadow-lg">
          <button role="menuitem" className="w-full text-left px-4 py-2 hover:bg-gray-100">
            Edit
          </button>
          <button role="menuitem" className="w-full text-left px-4 py-2 hover:bg-gray-100">
            Delete
          </button>
        </div>
      )}
    </div>
  );
}

Testing these interactions requires querying elements by their accessibility roles rather than CSS classes. You can verify that your menuitems receive focus correctly by writing tests with standard role queries, similar to the approaches covered in Testing React Components: Role Queries & Async State. Screen reader users will thank you when your components respect their input devices. Don't leave accessibility as an afterthought.

Be careful with CSS pointer-events properties when building these components. If you disable mouse interaction incorrectly, you break touch screen devices that simulate mouse clicks. Always ensure that touch events map cleanly to your click handlers and that focus rings remain visible for keyboard users browsing your site in high-contrast or standard desktop modes.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Streaming Structured LLM Outputs

    Streaming Structured LLM Outputs

    Building Accessible Dropdown Menus in React

    Building Accessible Dropdown Menus in React

    Isolating Server Components in React and Next Applications

    Isolating Server Components in React and Next Applications

    Securing Daemons with Systemd Dynamic Users

    Securing Daemons with Systemd Dynamic Users

    Strict Null Safety With Pydantic V2 Validators

    Strict Null Safety With Pydantic V2 Validators

    Diagnosing Async Python Event Loop Stalls

    Diagnosing Async Python Event Loop Stalls

    Synchronizing State Across Parallel Next.js Routes

    Synchronizing State Across Parallel Next.js Routes

    LLM API Circuit Breaker Pattern

    LLM API Circuit Breaker Pattern

    Strict TypeScript Discriminated Unions for React Forms

    Strict TypeScript Discriminated Unions for React Forms

    Fixing Next.js Server Action Timeout Errors

    Fixing Next.js Server Action Timeout Errors