How to Build an Accessible React Dropdown Menu (With Code and Testing)

Learn how to build an accessible React dropdown menu: correct ARIA roles, keyboard support, headless component code, testing, and common pitfalls to avoid.

ASOasis
8 min read
How to Build an Accessible React Dropdown Menu (With Code and Testing)

Image used for representation purposes only.

Why accessible dropdowns matter

Dropdown menus are everywhere in React apps—from action menus to navigation and filters. Yet many custom implementations break keyboard navigation, confuse screen readers, and fail on touch devices. Building them accessibly is not only the right thing to do; it also reduces bugs and improves usability for everyone.

This guide shows how to design and code an accessible React dropdown menu (menu button pattern), explain when to use different ARIA patterns, and provide a production-ready example with testing tips.

Pick the right pattern first

Before writing code, confirm what you’re actually building. Different UI intents map to different semantic patterns:

  • Choose native select when the user picks a single value from a known list (form fields). It’s the most accessible and requires no custom scripting.
  • Choose menu button (disclosure + menu) for a small set of actions like Edit, Duplicate, Delete. That’s our focus here.
  • Choose navigation disclosure for showing/hiding a list of links in a navbar. Don’t use role=“menu” for page navigation.
  • Choose listbox/combobox for searchable or multiselect options (autocomplete). These have different roles and keyboard rules.

If you’re unsure, ask: “Is this a set of immediate actions or a value selection?” Actions → menu button. Value selection → select/listbox/combobox.

Keyboard behavior requirements (menu button)

Implement these interactions consistently:

  • Button
    • Enter/Space toggles the menu and moves focus to the first item.
    • ArrowDown opens the menu and focuses the first item; ArrowUp opens and focuses the last item.
    • Escape closes the menu.
  • Menu
    • Up/Down moves focus between items (roving focus).
    • Home/End jumps to first/last item.
    • Typeahead focuses the next item starting with typed characters (optional but nice).
    • Enter/Space/Click activates the focused item.
    • Escape closes the menu and returns focus to the button.
  • Outside click, Tab, or Shift+Tab should close the menu.

Minimal, accessible structure

  • A button that controls a popup list via aria-expanded and aria-controls.
  • A popup container with role=“menu” and each actionable child with role=“menuitem” (or menuitemcheckbox/menuitemradio when appropriate).
  • Manage focus: the menu takes focus when opened; items are not tabbable individually (tabIndex=-1) and are focused programmatically.

React implementation (headless, reusable)

The following component implements a robust menu button using React hooks. It favors semantics and interaction over styling so you can bring your own CSS or Tailwind.

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

type MenuItem = {
  id?: string;
  label: string;
  onSelect: () => void;
  disabled?: boolean;
};

type DropdownProps = {
  buttonLabel: string;
  items: MenuItem[];
  align?: 'start' | 'end';
};

export function Dropdown({ buttonLabel, items, align = 'start' }: DropdownProps) {
  const buttonRef = useRef<HTMLButtonElement>(null);
  const menuRef = useRef<HTMLDivElement>(null);
  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState<number>(-1);
  const [typeahead, setTypeahead] = useState('');
  const tid = useRef<number | null>(null);

  const menuId = useId();
  const labelId = useId();

  // Focusable indices (skip disabled)
  const focusable = useMemo(
    () => items.map((it, i) => ({ i, disabled: !!it.disabled })).filter(x => !x.disabled).map(x => x.i),
    [items]
  );

  const firstIndex = focusable[0] ?? -1;
  const lastIndex = focusable[focusable.length - 1] ?? -1;

  useEffect(() => {
    function onDocClick(e: MouseEvent) {
      if (!open) return;
      const t = e.target as Node;
      if (!menuRef.current?.contains(t) && !buttonRef.current?.contains(t)) {
        setOpen(false);
        setActiveIndex(-1);
        buttonRef.current?.focus();
      }
    }
    function onDocKey(e: KeyboardEvent) {
      if (!open) return;
      if (e.key === 'Tab') {
        setOpen(false);
      }
    }
    document.addEventListener('mousedown', onDocClick);
    document.addEventListener('keydown', onDocKey);
    return () => {
      document.removeEventListener('mousedown', onDocClick);
      document.removeEventListener('keydown', onDocKey);
    };
  }, [open]);

  useEffect(() => {
    if (!open) return;
    // Move focus to active item or container
    const el = menuRef.current?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`);
    if (el) el.focus();
    else (menuRef.current as HTMLElement | null)?.focus();
  }, [open, activeIndex]);

  function openAndFocus(index: number) {
    setOpen(true);
    setActiveIndex(index);
  }

  function onButtonKeyDown(e: React.KeyboardEvent) {
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      openAndFocus(firstIndex);
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      openAndFocus(lastIndex);
    } else if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      openAndFocus(firstIndex);
    } else if (e.key === 'Escape') {
      setOpen(false);
    }
  }

  function move(delta: 1 | -1) {
    const order = focusable;
    if (!order.length) return;
    const curr = activeIndex === -1 ? (delta === 1 ? firstIndex : lastIndex) : activeIndex;
    const pos = order.indexOf(curr);
    const next = order[(pos + delta + order.length) % order.length];
    setActiveIndex(next);
  }

  function onMenuKeyDown(e: React.KeyboardEvent) {
    switch (e.key) {
      case 'ArrowDown': e.preventDefault(); move(1); break;
      case 'ArrowUp': e.preventDefault(); move(-1); break;
      case 'Home': e.preventDefault(); setActiveIndex(firstIndex); break;
      case 'End': e.preventDefault(); setActiveIndex(lastIndex); break;
      case 'Enter':
      case ' ': {
        e.preventDefault();
        const item = items[activeIndex];
        if (item && !item.disabled) {
          item.onSelect();
          setOpen(false);
          buttonRef.current?.focus();
        }
        break;
      }
      case 'Escape': {
        e.preventDefault();
        setOpen(false);
        buttonRef.current?.focus();
        break;
      }
      default: {
        // Typeahead: focus next item starting with the buffer
        if (e.key.length === 1 && /\S/.test(e.key)) {
          const nextBuffer = (typeahead + e.key).toLowerCase();
          setTypeahead(nextBuffer);
          if (tid.current) window.clearTimeout(tid.current);
          tid.current = window.setTimeout(() => setTypeahead(''), 500);
          const start = activeIndex === -1 ? 0 : (focusable.indexOf(activeIndex) + 1);
          const pool = focusable.map(i => items[i].label.toLowerCase());
          const rotated = pool.slice(start).concat(pool.slice(0, start));
          const foundRel = rotated.findIndex(lbl => lbl.startsWith(nextBuffer));
          if (foundRel !== -1) {
            const absolute = focusable[(start + foundRel) % focusable.length];
            setActiveIndex(absolute);
          }
        }
      }
    }
  }

  function onItemClick(i: number) {
    const it = items[i];
    if (!it || it.disabled) return;
    it.onSelect();
    setOpen(false);
    buttonRef.current?.focus();
  }

  return (
    <div className="dropdown" style={{ position: 'relative', display: 'inline-block' }}>
      <button
        ref={buttonRef}
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={menuId}
        id={labelId}
        type="button"
        onClick={() => (open ? setOpen(false) : openAndFocus(firstIndex))}
        onKeyDown={onButtonKeyDown}
      >
        {buttonLabel}
        <span aria-hidden></span>
      </button>

      {open && (
        <div
          ref={menuRef}
          id={menuId}
          role="menu"
          aria-labelledby={labelId}
          tabIndex={-1}
          onKeyDown={onMenuKeyDown}
          style={{
            position: 'absolute',
            minWidth: '180px',
            [align === 'end' ? 'right' : 'left']: 0,
            marginTop: 4,
            background: 'white',
            border: '1px solid #ddd',
            borderRadius: 6,
            boxShadow: '0 10px 25px rgba(0,0,0,.12)',
            padding: 4,
          }}
        >
          {items.map((it, i) => {
            const disabled = !!it.disabled;
            const isActive = i === activeIndex;
            return (
              <div
                key={it.id ?? i}
                role="menuitem"
                aria-disabled={disabled || undefined}
                data-index={i}
                tabIndex={isActive ? 0 : -1}
                onClick={() => onItemClick(i)}
                onMouseEnter={() => !disabled && setActiveIndex(i)}
                style={{
                  padding: '8px 10px',
                  cursor: disabled ? 'not-allowed' : 'pointer',
                  borderRadius: 4,
                  outline: isActive ? '2px solid #3b82f6' : 'none',
                  background: isActive ? 'rgba(59,130,246,.1)' : 'transparent',
                  color: disabled ? '#aaa' : 'inherit'
                }}
              >
                {it.label}
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

Usage:

<Dropdown
  buttonLabel="More actions"
  items=[
    { label: 'Edit', onSelect: () => console.log('Edit') },
    { label: 'Duplicate', onSelect: () => console.log('Duplicate') },
    { label: 'Archive', onSelect: () => console.log('Archive'), disabled: true },
    { label: 'Delete', onSelect: () => console.log('Delete') },
  ]
/>

Notes:

  • Items use role=“menuitem” and are programmatically focused (tabIndex toggles 0/-1) to keep a single tab stop.
  • The button exposes aria-haspopup=“menu”, aria-expanded, and aria-controls to tie it to the popup.
  • We close the menu on outside click, Tab navigation, Escape, and activation.
  • Typeahead search improves large menus but is optional.

Styling, positioning, and portals

  • Prefer CSS for layout and visual focus indicators; avoid removing outlines. Customize the focus style to meet contrast requirements (3:1 minimum for focused states).
  • If the menu might overflow a scroll container, render it in a portal and position with a library like Floating UI. Keep aria-controls and focus logic the same.
  • Ensure the popup isn’t clipped by parent overflow: hidden.

Variants: checkbox and radio items

For toggles inside menus, change roles accordingly:

  • role=“menuitemcheckbox” with aria-checked=“true|false” for independent toggles.
  • role=“menuitemradio” with aria-checked to indicate the selected option within a group. Group with role=“group” and aria-label.

Keyboard and activation remain the same.

Common pitfalls (and fixes)

  • Treating navigation links as a menu: Link lists for site nav should not use role=“menu”. Use a disclosure pattern and normal anchors.
  • Making every menu item tabbable: That creates a long tab stop chain. Use roving focus instead.
  • Forgetting focus return: Always restore focus to the invoking button when the menu closes.
  • Hover-only menus: They fail on touch and keyboard. Always support click and keyboard.
  • Non-interactive wrappers: Don’t put role=“menuitem” on non-activatable containers; use a button or anchor under the role.

Testing accessibility

Automated checks don’t replace manual tests, but they catch regressions.

  • Unit/integration tests with React Testing Library and user-event
import { render, screen } from '@testing-library/react';
import user from '@testing-library/user-event';

test('opens with ArrowDown and focuses first item', async () => {
  render(<Dropdown buttonLabel="More" items={[{label:'Edit', onSelect: jest.fn()}]} />);
  const btn = screen.getByRole('button', { name: /more/i });
  await user.click(btn);
  expect(screen.getByRole('menu')).toBeInTheDocument();
  expect(screen.getByRole('menuitem', { name: /edit/i })).toHaveFocus();
});
  • E2E with Cypress: verify keyboard flows, outside click, and Escape.
  • Screen reader spot checks
    • macOS: VoiceOver + Safari or Chrome.
    • Windows: NVDA/JAWS + Firefox/Chrome.
    • Confirm button announces “menu, collapsed/expanded,” items are read as menu items, and focus moves as expected.

When to use a library

If you don’t need full control or time is tight, mature headless libraries ship this behavior:

  • Radix UI (Dropdown Menu)
  • Reach UI (Menu Button)
  • Headless UI (Menu)
  • React Aria (useMenu, useMenuTrigger)

These typically cover RTL, typeahead, collision-aware positioning, and complex roles. You still own styling and content.

Performance and ergonomics

  • Keep menus small; large lists may be better as a combobox with search.
  • Memoize item arrays to avoid re-renders if you pass callbacks.
  • Defer heavy work until activation; menus are ephemeral UI.

Accessibility checklist

Use this quick pass before shipping:

  • The trigger is a real button with aria-expanded and aria-controls.
  • The popup has role=“menu”; items have appropriate menuitem roles.
  • Only one tab stop: the button. Items are focused with arrow keys.
  • Escape, Tab/Shift+Tab, and outside click close the menu and restore focus.
  • Arrow keys, Home/End, and optional typeahead all work.
  • Focus styles are visible and high-contrast.
  • Screen readers announce state changes clearly.

Conclusion

Accessible dropdowns aren’t about memorizing ARIA—they’re about mapping intent to the right pattern and honoring predictable interactions. Start simple, lean on semantics, and test with keyboards and screen readers. Whether you roll your own or adopt a headless library, the result will be faster to use, easier to maintain, and friendlier to all users.

Related Posts