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.
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.
<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.
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.
{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.
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
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.
Frequently Asked Questions
Should the trigger be a button or a div?
How do I close the menu when the user clicks away?
What does aria-expanded communicate?
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.
More in this topic
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.
Compound Components in React: Build Flexible UI APIs
Build flexible React component APIs with the compound components pattern. Share state through context and let users arrange the parts.