Patterns Menu (button-triggered)

Menu (button-triggered)

A button that reveals a list of actions. Different from a navigation list — menus require a specific keyboard model and ARIA wiring most implementations get wrong.

Reviewed against the methodology checklist Updated

Try the keyboard

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 listrole="menu"none — list with links
Role on itemsrole="menuitem"none — <a href>
Items in tab orderNo, only the triggerYes
Movement keyArrowsTab
Close on selectionYesn/a

If you’re unsure, ask: does this open another page or perform an action on this page? Page → links. Action → menu.

The pattern

Accessible menu (button + popup menu)

The structure is the WAI-ARIA APG menu button pattern (opens in new tab):

HTML
<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 keyboard model

This is the part most “menu” implementations get wrong:

JS
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:

  1. 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.
  2. 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.
  3. 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:

JS
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):

JS
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.

HTML
<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

HTML
<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

Checklist

Screen reader transcript

Voiced by George — reading what VoiceOver on macOS Safari announces. Other readers (NVDA, JAWS) phrase things differently; the meaning is what matters.

Screen reader announcements for each step
ListenYou doScreen reader says
Tab onto the triggerActions, menu, collapsed, button.
Press EnterEdit profile, menu item. (focus moves into the menu)
Press Down arrowPause subscription, menu item.
Press Escape(menu closes; focus returns to "Actions" button)

Screen reader transcript — anti-pattern

Same interaction, but on the broken version above. Notice what the user is missing.

Screen reader announcements for each step
ListenYou doScreen reader says
Tab onto a "menu" built from linksEdit profile, link.
Tab againPause subscription, link. (every "menu item" is in the tab order — Tab cycles through them all even when the menu is closed)
Press Down arrow(nothing happens — no keyboard handler)

WCAG references