Navigation
Menu Button
AvailableA button that opens a menu on activation. Uses aria-haspopup and aria-expanded to convey the relationship.
What Is a Menu Button?
A menu button is a button that, when pressed, pops up a list of actions (a menu). Think of a "⋯" or "Actions ▾" button that reveals options like "Duplicate, Rename, Delete." It lets you provide a collection of commands without taking up extra space.
Note
The "menu" here refers to application commands. For site navigation, you typically don't use role="menu". Instead, use <nav> with a list of links (see the Menubar page for details).
Why Does Accessibility Matter?
- People who use only a keyboard. A
:hover-dependent dropdown can never be opened without a mouse. Users need to open it with Enter or ↓, navigate items with ↑↓, and close it with Esc. - People who use screen readers. With
aria-haspopup="menu", users know this button opens a menu, andaria-expandedconveys the open/closed state. It's essential to move focus into the menu when it opens andreturn focus to the trigger when it closes.
Live Demo (Recommended Implementation)
The menu button below follows the APG pattern. Try operating it without a mouse.
Try it: Tab to the button → Enter / Space / ↓ to open (focus moves to the first item) → ↑ ↓ to navigate items, Home / End to jump to edges → Enter to execute → Esc to close and return to the button.
Tip
Focus management during open/close is key. Move focus to the first item when opened, and return focus to the button when closed with Esc, so keyboard users never lose track of where they are.
Keyboard Interaction
| Key | Action | Priority |
|---|---|---|
| Enter / Space / Down Arrow (on the button) | Open the menu and move focus to the first item | Required |
| Up Arrow (on the button) | Open the menu and move focus to the last item | Optional |
| Up / Down Arrow (within the menu) | Move focus to the previous / next item | Required |
| Home / End | Move to the first / last item | Recommended |
| Enter / Space (on an item) | Activate the item, close the menu, and return focus to the button | Required |
| Esc | Close the menu and return focus to the button | Required |
Required WAI-ARIA Roles and Properties
| Target | Attribute / Role | Meaning |
|---|---|---|
| Trigger | <button> + aria-haspopup="menu" | Conveys that this is a button that opens a menu when pressed. |
| Trigger | aria-expanded="true | false" | Indicates whether the menu is open. Must be updated whenever the menu opens or closes. |
| Trigger | aria-controls="menu-id" | Identifies which menu the button opens. |
| Menu container | role="menu" + aria-labelledby | Conveys that this is a menu and that it is labeled by the trigger. |
| Each item | role="menuitem" + tabindex="-1" | Conveys that this is a menu item. Focus is managed via JavaScript (roving tabindex). |
| Closed menu | hidden | Removes the menu from screen reader announcements and interaction while closed. |
Implementation: Recommended Pattern (Good)
Good / Recommended
The trigger uses <button aria-haspopup="menu" aria-expanded>, and the menu usesrole="menu" + role="menuitem". Focus management on open/close and Esc return are implemented in JS.
Markup:
<div class="menu">
<button type="button"
id="menu-button-trigger"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="menu-button-list">
Actions ▾
</button>
<ul id="menu-button-list"
role="menu"
aria-labelledby="menu-button-trigger"
hidden>
<li role="menuitem" tabindex="-1">Duplicate</li>
<li role="menuitem" tabindex="-1">Rename</li>
<li role="menuitem" tabindex="-1">Delete</li>
</ul>
</div>Open/close and focus management script:
const trigger = document.getElementById('menu-button-trigger');
const menu = document.getElementById('menu-button-list');
const items = Array.from(menu.querySelectorAll('[role="menuitem"]'));
function open(focusIndex) {
menu.hidden = false;
trigger.setAttribute('aria-expanded', 'true');
items[focusIndex].focus(); // Move focus into the menu when opened
}
function close(returnFocus = true) {
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
if (returnFocus) trigger.focus(); // Return focus to trigger on Esc, etc.
}
trigger.addEventListener('click', () =>
menu.hidden ? open(0) : close());
trigger.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); open(0);
} else if (e.key === 'ArrowUp') {
e.preventDefault(); open(items.length - 1);
}
});
items.forEach((item, i) => {
item.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1) % items.length].focus(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length].focus(); }
else if (e.key === 'Home') { e.preventDefault(); items[0].focus(); }
else if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus(); }
else if (e.key === 'Escape') { close(); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); run(item); }
});
item.addEventListener('click', () => run(item));
});
function run(item) { /* Execute action */ close(); }
// Close the menu when clicking outside
document.addEventListener('click', (e) => {
if (!menu.hidden && !e.target.closest('.menu')) close(false);
});Anti-pattern (Bad)
Below is a dropdown that only opens on :hover.It opens when you hover with a mouse, but it's completely inoperable with a keyboard.
- Duplicate
- Rename
- Delete
Try it: Hovering opens it, but you can't focus the trigger with Tab, and neither ↓ nor Esc work. Moving the mouse away closes it immediately.
<!-- ❌ Anti-pattern: dropdown that only opens on hover -->
<div class="dropdown">
<div class="trigger">Actions ▾</div>
<!-- Only shown via CSS while hovering. No ARIA, no keyboard support -->
<ul class="menu-list">
<li onclick="run('copy')">Duplicate</li>
<li onclick="run('rename')">Rename</li>
<li onclick="run('delete')">Delete</li>
</ul>
</div>
<style>
.menu-list { display: none; }
.dropdown:hover .menu-list { display: block; } /* hover-dependent */
</style>Bad / Avoid
Problems with this implementation:
- Cannot be opened with a keyboard — The
divtrigger is not focusable. ↓/Enter have no effect. - Hover-dependent — Breaks for people who can't use a mouse and in touch environments. Closes as soon as the mouse moves away.
- Role and state are not communicated — No
aria-haspopup/aria-expanded/role="menu". - Cannot close with Esc and no focus management — Users lose track of where they are.
Implementation Checklist
- Trigger is a button element with aria-haspopup="menu" + aria-expanded + aria-controls
- Menu has role="menu"; items have role="menuitem" + tabindex="-1"
- Click / Enter / Space / Down Arrow opens the menu and moves focus to the first item
- Arrow Up / Down navigates within the menu; Home / End move to the ends
- Enter / Space activates the item, closes the menu, and returns focus to the trigger
- Escape closes the menu and returns focus to the trigger
- Clicking outside the menu closes it
- aria-expanded always matches the open/closed state and focus is visible
Source (English):Menu Button Pattern — W3C APG(opens in a new tab)