How to build an accessible MenuButton in React
The trigger is a real <button> exposing aria-haspopup="menu", aria-expanded reflecting open state, and aria-controls pointing at the role="menu" panel. ArrowDown/Enter/Space open the menu and move focus to the first enabled item, ArrowUp opens and focuses the last item, ArrowUp/ArrowDown rove focus between items with wrap-around, disabled items are aria-disabled and skipped by roving focus, Escape closes and restores focus to the trigger, and Tab closes without trapping focus. DismissableLayer provides outside-pointer and Escape dismissal, and high-contrast plus reduced-motion preferences are honored in CSS.
When to use a MenuButton
- Exposing a small set of one-shot actions behind a single labeled button
- Toolbar or header overflow actions where each item runs a command on activation
- Action lists that need ArrowUp/ArrowDown roving focus and Enter/Space activation
When not to use it
- Choosing and persisting a value from options — use Select
- Right-click contextual actions on a target element — use ContextMenu
- A single action with no list — use a plain Button
- Rich interactive content (forms, pickers) rather than a list of commands — use Popover
Keyboard interactions
Role menu, verified at WCAG 2.2-AA.
ArrowDownArrowUpEnterSpaceEscapeTab
Common mistakes
Avoid: Rendering checkbox or radio state inside MenuButton items and treating selection as a chosen value
Prefer: Use Select or MultiSelect for value selection; MenuButton items are one-shot actions that close the menu on activation
role="menuitem" items do not model a selected value and dismiss the menu when activated, so they cannot represent persistent choices
Avoid: Leaving the trigger label empty with no visible text and no labels.open override
Prefer: Provide visible label text, or pass labels.open / rely on the built-in i18n accessible name
A trigger with no accessible name is unusable for screen-reader and voice-control users
Example
<MenuButton
label="Actions"
items={[
{ id: 'edit', label: 'Edit', onSelect: () => edit() },
{ id: 'duplicate', label: 'Duplicate', onSelect: () => duplicate() },
{ id: 'delete', label: 'Delete', onSelect: () => remove(), disabled: !canDelete },
]}
/>