When to use (and when not to)
Use the menu pattern for a list of actions revealed by a button — “Edit profile”, “Pause subscription”, “Delete account”. Each item does something; this is a sibling of button, not navigation.
For navigation (a list of links to other pages), the menu pattern is wrong. Use a real <nav> with a <ul> of links. The roles and keyboard model differ:
| Button menu (this pattern) | Nav links | |
|---|---|---|
| Role on the list | role="menu" | none — list with links |
| Role on items | role="menuitem" | none — <a href> |
| Items in tab order | No, only the trigger | Yes |
| Movement key | Arrows | Tab |
| Close on selection | Yes | n/a |
If you’re unsure, ask: does this open another page or perform an action on this page? Page → links. Action → menu.
The pattern
The structure is the WAI-ARIA APG menu button pattern (opens in new tab):
<button
type="button"
id="actions-trigger"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="actions-menu"
>
Actions ▾
</button>
<ul
role="menu"
id="actions-menu"
aria-labelledby="actions-trigger"
hidden
>
<li role="none">
<button type="button" role="menuitem">Edit profile</button>
</li>
<li role="none">
<button type="button" role="menuitem">Pause subscription</button>
</li>
<li role="separator"></li>
<li role="none">
<button type="button" role="menuitem">Delete account</button>
</li>
</ul> A few subtleties:
- The trigger has
aria-haspopup="menu"so screen readers announce “menu, button” — the user knows pressing Enter will open something. aria-expandedflips between"true"and"false"as the menu opens/closes — announced as “expanded”/“collapsed”.- The list has
role="menu"and items haverole="menuitem". The<li>wrappers getrole="none"so the list semantics don’t compete. - Items are still real
<button>elements, even withrole="menuitem"— gives us focus and Enter/Space activation for free. <li role="separator">(or<hr role="separator">) groups items visually and audibly.
The keyboard model
This is the part most “menu” implementations get wrong:
trigger.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
open('first'); // focus first item
} else if (e.key === 'ArrowUp') {
e.preventDefault();
open('last'); // focus last item
}
});
menu.addEventListener('keydown', (e) => {
const idx = items.indexOf(document.activeElement);
if (e.key === 'ArrowDown') items[(idx + 1) % items.length].focus();
else if (e.key === 'ArrowUp') items[(idx - 1 + items.length) % items.length].focus();
else if (e.key === 'Home') items[0].focus();
else if (e.key === 'End') items[items.length - 1].focus();
else if (e.key === 'Escape') { close(); trigger.focus(); }
else if (e.key === 'Tab') close(false); // close, but let Tab move focus naturally
}); Three things people miss:
- Up arrow on the trigger opens with the last item focused. This is in the APG spec because users often press Up to jump to “Delete account” or whatever’s at the bottom.
- Tab closes the menu — but doesn’t preventDefault. Tab moves focus to whatever’s next in the document, the menu just closes on the way out.
- Escape closes and returns focus to the trigger. Without this, Escape hides the menu but keyboard focus is now stuck on a hidden item.
Selecting an item
Activating an item performs the action and closes the menu:
items.forEach((item) => {
item.addEventListener('click', () => {
item.dataset.action; // do whatever the action is
close();
});
}); Don’t auto-close on focus change inside the menu. Users may arrow up and down before deciding.
Outside click
A click anywhere outside the menu closes it (without returning focus to the trigger — focus follows the click):
document.addEventListener('click', (e) => {
if (!stage.contains(e.target)) close(false);
}); Anti-patterns
<select> styled to look like a menu
<select> is a form control for choosing one of N values. It looks similar visually but its role and keyboard model are different — <select> doesn’t trigger actions on selection, screen readers announce it as “combobox” or “list box”, and you can’t put separators or destructive items in it.
If you’re picking a value, use <select> (with a real <label>). If you’re triggering actions, use the menu pattern above.
Links in a menu
<ul role="menu">
<li role="none"><a href="/profile" role="menuitem">Profile</a></li>
<li role="none"><a href="/settings" role="menuitem">Settings</a></li>
</ul> If they’re navigation links, drop the menu roles entirely and use a regular <nav> with a <ul> of <a> elements. The menu pattern is for actions; misusing it means screen readers expect arrow-key navigation that links don’t support.
Always-visible “menu” with no trigger
<ul role="menu">
<li role="none"><button role="menuitem">… A role="menu" without a button that triggers it isn’t a menu — it’s just a list with confusing semantics. If the items are always visible, drop the menu role and use a regular list of buttons.
Common variations
- Menu items with sub-menus — APG submenu pattern, opened by Right arrow, closed by Left. Be sure you actually need this; flat menus are easier to use and easier to author.
- Checkable items —
role="menuitemcheckbox"orrole="menuitemradio"for items that toggle state. Addaria-checked="true|false". - First-letter typeahead — pressing a letter focuses the next item starting with that letter. Cheap to add and a delight for keyboard users.
- Right-click context menus — different pattern. Browsers have native context menus; the web platform’s
contextmenuevent is largely deprecated. If you need a custom one, follow the same APG menu pattern, opened oncontextmenuevent withpreventDefault.
Checklist
- Trigger is a real
<button>witharia-haspopup="menu"andaria-expanded - Menu has
role="menu",aria-labelledby="<trigger-id>" - Items are real
<button>elements withrole="menuitem";<li>s arerole="none" - Enter / Space / Down arrow on trigger → open + focus first item
- Up arrow on trigger → open + focus last item
- Down / Up arrows in menu cycle (wrap)
- Home / End jump to first / last
- Escape closes and returns focus to the trigger
- Tab closes and lets focus continue naturally
- Click outside closes the menu
-
<li role="separator">(or<hr role="separator">) for grouping