How to Build a Dropdown Menu in React

Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.

6 min read

A React dropdown menu is a button that reveals a list of actions, built with the ARIA menu button pattern. The key work is naming the button for assistive technology, closing the menu when a click lands outside, and keeping the keyboard path smooth.

The menu button

The trigger is a plain button with two ARIA attributes. aria-haspopup says it opens a menu, and aria-expanded reports whether that menu is showing. The component holds one state value for whether the menu is open, plus a ref for the button and a ref for the wrapping element that the next sections use for focus and outside clicks.

App.jsxApp.jsx
import { useRef, useState } from "react";
 
const [open, setOpen] = useState(false);
const buttonRef = useRef(null);
const rootRef = useRef(null);

The trigger button lives inside the root element and reads its ARIA attributes from that state. Wrapping both the button and the eventual menu in the same root element is what lets the outside-click handler in the next section tell an inside click from an outside one.

App.jsxApp.jsx
<div ref={rootRef}>
  <button
    ref={buttonRef}
    aria-haspopup="menu"
    aria-expanded={open}
    onClick={() => setOpen((value) => !value)}
  >
    {label}
  </button>
</div>

The button toggles the open state, and aria-expanded follows it. Because it is a real button, Enter and Space activate it for free, which a div would not.

Close on outside clicks

Users expect a click anywhere else to dismiss the menu. An Effect listens for a document mousedown and closes the menu when the click lands outside the root.

App.jsxApp.jsx
useEffect(() => {
  function handleClick(event) {
    if (rootRef.current && !rootRef.current.contains(event.target)) {
      setOpen(false);
    }
  }
  document.addEventListener("mousedown", handleClick);
  return () => document.removeEventListener("mousedown", handleClick);
}, []);

The root ref wraps both the button and the menu, so a click on either does not close it. The cleanup removes the listener when the component unmounts, which keeps the global listener from leaking.

Render the menu items

The menu is a list with the menu role, and each item is a button with the menuitem role.

App.jsxApp.jsx
{open && (
  <ul role="menu">
    {items.map((item) => (
      <li key={item.id}>
        <button role="menuitem"
          onClick={() => { onSelect(item.id); setOpen(false); }}>
          {item.label}
        </button>
      </li>
    ))}
  </ul>
)}

Selecting an item runs the action and closes the menu. The li elements give the list structure, while the buttons carry the interactive role and keep keyboard support.

Add Escape and arrow keys

Escape should close the menu and return focus to the trigger, and the arrow keys should move through the items. A keydown handler on the menu covers both.

App.jsxApp.jsx
function handleMenuKeyDown(event) {
  if (event.key === "Escape") {
    setOpen(false);
    buttonRef.current?.focus();
  }
}

The handler closes the menu and refocuses the trigger so the user lands back where they started. ArrowDown and ArrowUp follow the same shape by focusing the next or previous menuitem, which you can wire with a small ref map of item buttons.

Move focus when the menu opens

When the menu opens, focus should move to the first item so keyboard users are already inside, and it should return to the trigger when the menu closes. The ARIA menu button pattern names this as the expected behavior, and it is the same focus work any overlay requires.

Building all of this by hand is educational, but a library removes the repetitive focus and key handling. Arrow key navigation adds another layer, since each key must find the next menuitem and focus it. The headless UI components article covers that tradeoff, and the shared state between trigger and menu is the compound components pattern.

Rune AI

Rune AI

Key Insights

  • Mark the trigger with aria-haspopup and aria-expanded.
  • Render the items in a list with role menu and role menuitem.
  • Close on a mousedown outside the root.
  • Close on Escape and return focus to the button.
  • Use a button for the trigger, never a div.
RunePowered by Rune AI

Frequently Asked Questions

Should the trigger be a button or a div?

A button. It is keyboard focusable by default, announces itself as a control, and works with Enter and Space without extra code.

How do I close the menu when the user clicks away?

Listen for a document mousedown and close the menu when the click lands outside the root element. Remove the listener when the component unmounts.

What does aria-expanded communicate?

aria-expanded tells assistive technology whether the menu is open, and it must flip between true and false as the menu appears and disappears.

Conclusion

A dropdown menu is a button that toggles a menu of items, with aria-haspopup and aria-expanded on the button and role menu on the list. Close it on outside clicks, Escape, and item selection, and move focus between the button and the items.