# Action menus that open silently, out of keyboard reach

Source: https://easeweb.dev/learn/menu-button
Topics: Complex widgets > Action menus say they open a menu and move focus into it (WCAG 2.1.1, 4.1.2)
The fix: Either build the menu button pattern: a button with aria-haspopup="menu" and aria-expanded, a role="menu" of menu items, focus moved to the first item as it opens, and Down Arrow, Up Arrow, Home, End and Escape. Or make it a disclosure: a button with aria-expanded that shows a list of plain buttons straight after it, which Tab moves through.
Test with: keyboard, screen reader, forced colors, zoom
References: https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/ https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/examples/menu-button-actions/ https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/ https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F59 https://www.w3.org/WAI/WCAG22/Techniques/failures/F15 https://www.w3.org/TR/wai-aria-1.2/#menu https://inclusive-components.design/menus-menu-buttons/

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A shop's order history shows each order on a card, with an Actions button that drops down four things to do with it: Buy again, Return items, Download invoice and Report a problem. Click Actions and the menu opens under the button; click Download invoice and the invoice downloads. The button is a real button, so Tab reaches it and Enter opens the menu. But focus stays on Actions, and the actions are divs with a click handler, so the next Tab passes the open menu and lands on View all orders, under the card. Down Arrow scrolls the page. Nothing in the menu can be reached without a mouse. A screen reader says "Actions, button", and when Enter is pressed nothing new is announced: not that the button opens a menu, and not that one has opened. Reading on with its own keys, it finds the four actions as plain words, with no role to say they can be pressed or that they belong to the button.

The team then makes it a menu the quick way: `aria-haspopup="menu"` on the button, `role="menu"` on the panel and `role="menuitem"` on each action, and nothing else. A screen reader now says the button opens a menu, but pressing it changes nothing more: focus stays on the button, nothing says the menu is open, Tab still passes every item, and the arrow keys a menu promises do nothing.

## Why it fails

Success criterion 2.1.1 Keyboard asks that everything the mouse can do, the keyboard can do. The mouse runs an action by clicking a div, and a div takes no focus, so the keyboard can open the menu but can't reach anything in it. Every action in the menu, Download invoice included, needs a mouse. The menu roles change nothing here: a role says what an element is, not how it behaves, so the items still take no focus, and no script answers the arrow keys.

Success criterion 4.1.2 Name, Role, Value asks that controls expose their role and state, and that changes to them reach assistive technology. The button exposes its name and role, but not that it opens a menu, or whether the menu is open, so pressing it changes nothing a screen reader can report. The actions expose no role at all: a click handler gives a div no role in the accessibility API, which is failure F59. With the roles added, the button says it opens a menu, but still not whether it is open, and focus stays on the button, so opening the menu is still silent. Roles without the behaviour they promise are failure F15: custom controls that use the accessibility API incompletely.

## Who is affected

Action menus sit on order histories, address books, file lists, team settings and comments, often one on every row, and on a phone the same button often holds the actions there is no room to show. Whatever is behind it is out of reach for anyone who can't click it, and unknown to anyone who can't see it open.

## What automation and AI miss

The first menu has a real button with a name, and divs with no role, so axe has nothing to report. The menu roles pass axe too: every `menuitem` sits in a `menu`, and each has a name. axe checks that the roles fit together, not whether focus ever reaches an item or what a key does.

The audit presses the keys. It finds buttons that say they open a menu with `aria-haspopup`, and buttons named More, Actions or Options, or with a class such as kebab, overflow or dropdown, outside the site's navigation and header. It opens each with Enter on a freshly loaded page, and looks at what appeared. If focus doesn't move into the menu, Down Arrow and Tab don't reach it either, and none of its actions is a Tab stop, that is reported. A `role="menu"` that takes focus, but whose arrow keys, Home and End never move focus on from the item it reached, is reported too. A button that leaves focus on itself as its menu opens, with no `aria-expanded`, is reported, and so is an `aria-expanded` that stays false. A menu whose items are Tab stops, with arrow keys that do nothing, goes to review. A menu button with any other name and no telling attribute or class is not found, nor is one in a header, and no check can tell whether the actions are the ones people need.

The second menu is what an AI fix can produce: it adds the roles to the existing divs and stops there, and the result passes axe. A fix can also put `tabindex="0"` on every item, which makes each one a Tab stop inside a menu whose arrow keys do nothing.

## Before: a button and a dropdown of clickable divs

```html
<article class="order">
  <h2>Order 1042</h2>
  <p>Delivered on 14 October</p>
  <div class="actions">
    <button type="button" class="actions-button">Actions</button>
    <div class="actions-menu" hidden>
      <div class="item" data-action="buy-again">Buy again</div>
      <div class="item" data-action="return">Return items</div>
      <div class="item" data-action="invoice">Download invoice</div>
      <div class="item" data-action="report">Report a problem</div>
    </div>
  </div>
</article>
```

```css
.actions { position: relative; }
.actions-menu { position: absolute; top: 100%; left: 0; min-width: 12rem; padding: 0.25rem; background: #fff; border: 1px solid #d4d4d8; border-radius: 0.5rem; }
.actions-menu .item { padding: 0.5rem 0.75rem; border-radius: 0.375rem; cursor: pointer; }
.actions-menu .item:hover { background: #f4f4f5; }
```

```js
const button = document.querySelector('.actions-button');
const menu = document.querySelector('.actions-menu');
button.addEventListener('click', () => {
  menu.hidden = !menu.hidden;
});
menu.addEventListener('click', (event) => {
  const item = event.target.closest('[data-action]');
  if (!item) return;
  menu.hidden = true;
  runAction(item.dataset.action);
});
```

The button opens the menu, but nothing moves focus into it, and the actions answer only `click` and take no focus, so the keyboard stops at the button. Nothing in the markup says the button opens anything, whether it is open, or that the words under it are actions.

## Before: menu roles added to the same divs

```html
<article class="order">
  <h2>Order 1042</h2>
  <p>Delivered on 14 October</p>
  <div class="actions">
    <button type="button" class="actions-button" aria-haspopup="menu">Actions</button>
    <div class="actions-menu" role="menu" hidden>
      <div class="item" role="menuitem" data-action="buy-again">Buy again</div>
      <div class="item" role="menuitem" data-action="return">Return items</div>
      <div class="item" role="menuitem" data-action="invoice">Download invoice</div>
      <div class="item" role="menuitem" data-action="report">Report a problem</div>
    </div>
  </div>
</article>
```

```css
.actions { position: relative; }
.actions-menu { position: absolute; top: 100%; left: 0; min-width: 12rem; padding: 0.25rem; background: #fff; border: 1px solid #d4d4d8; border-radius: 0.5rem; }
.actions-menu .item { padding: 0.5rem 0.75rem; border-radius: 0.375rem; cursor: pointer; }
.actions-menu .item:hover { background: #f4f4f5; }
```

```js
const button = document.querySelector('.actions-button');
const menu = document.querySelector('.actions-menu');
button.addEventListener('click', () => {
  menu.hidden = !menu.hidden;
});
menu.addEventListener('click', (event) => {
  const item = event.target.closest('[data-action]');
  if (!item) return;
  menu.hidden = true;
  runAction(item.dataset.action);
});
```

The CSS and the script are unchanged; the only difference is the three attributes. They promise a menu button: focus moving into the menu, the arrow keys, an open state. Nothing delivers it. The button now says it opens a menu, and then stays where it is, with no `aria-expanded` to say the menu opened. The items have a role but still take no focus, so Tab and the arrow keys never reach them. axe passes it.

## After: a menu button

```html
<article class="order">
  <h2>Order 1042</h2>
  <p>Delivered on 14 October</p>
  <div class="actions">
    <button type="button" class="actions-button" id="order-1042-actions" aria-haspopup="menu" aria-expanded="false" aria-controls="order-1042-menu">Actions</button>
    <div class="actions-menu" id="order-1042-menu" role="menu" aria-labelledby="order-1042-actions" hidden>
      <button type="button" role="menuitem" tabindex="-1" data-action="buy-again">Buy again</button>
      <button type="button" role="menuitem" tabindex="-1" data-action="return">Return items</button>
      <button type="button" role="menuitem" tabindex="-1" data-action="invoice">Download invoice</button>
      <button type="button" role="menuitem" tabindex="-1" data-action="report">Report a problem</button>
    </div>
  </div>
</article>
```

```css
.actions { position: relative; }
.actions-menu { position: absolute; top: 100%; left: 0; min-width: 12rem; padding: 0.25rem; background: #fff; border: 1px solid #d4d4d8; border-radius: 0.5rem; }
.actions-menu [role='menuitem'] { display: block; width: 100%; padding: 0.5rem 0.75rem; border: 0; border-radius: 0.375rem; background: none; font: inherit; text-align: left; cursor: pointer; }
.actions-menu [role='menuitem']:is(:hover, :focus) { background: #f4f4f5; }
.actions-menu [role='menuitem']:focus-visible { outline: 2px solid #2563eb; outline-offset: -2px; }
```

```js
const actions = document.querySelector('.actions');
const button = actions.querySelector('.actions-button');
const menu = document.getElementById(button.getAttribute('aria-controls'));
const items = [...menu.querySelectorAll('[role="menuitem"]')];
function setOpen(open) {
  menu.hidden = !open;
  button.setAttribute('aria-expanded', String(open));
}
// Enter, Space or a click opens the menu and moves focus to its first action.
button.addEventListener('click', () => {
  setOpen(menu.hidden);
  if (!menu.hidden) items[0].focus();
});
// Down and Up move through the actions and wrap round, Home and End go to the ends,
// and Escape closes the menu and goes back to its button.
menu.addEventListener('keydown', (event) => {
  const at = items.indexOf(document.activeElement);
  const to = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: items.length - 1 }[event.key];
  if (to !== undefined) {
    event.preventDefault();
    items[(to + items.length) % items.length].focus();
  } else if (event.key === 'Escape') {
    setOpen(false);
    button.focus();
  }
});
menu.addEventListener('click', (event) => {
  const item = event.target.closest('[data-action]');
  if (!item) return;
  setOpen(false);
  button.focus();
  runAction(item.dataset.action);
});
// Focus or a click anywhere else, such as Tab out of the menu, closes it.
for (const type of ['focusin', 'click']) {
  document.addEventListener(type, (event) => {
    if (!actions.contains(event.target)) setOpen(false);
  });
}
```

This is the menu button pattern of the ARIA Authoring Practices. `aria-haspopup="menu"` makes a screen reader say that the button opens a menu, and `aria-expanded` says whether it is open. Enter, Space or a click opens it and moves focus to Buy again, so a screen reader announces the menu, named after its button, and the item, usually with its place: 1 of 4. The items are buttons, so Enter and Space run them with no key handling to write, and `role="menuitem"` gives them the role a menu holds. `tabindex="-1"` keeps them out of the Tab order, and the script moves focus between them: Down and Up Arrow, wrapping round at the ends, Home and End. Escape closes the menu and puts focus back on Actions, and so does running an action. Tab leaves the menu for the next thing on the page, and the menu closes behind it.

## After: a disclosure of plain buttons

```html
<article class="order">
  <h2>Order 1042</h2>
  <p>Delivered on 14 October</p>
  <div class="actions">
    <button type="button" class="actions-button" aria-expanded="false" aria-controls="order-1042-menu">Actions</button>
    <ul class="actions-menu" id="order-1042-menu" hidden>
      <li>
        <button type="button" data-action="buy-again">Buy again</button>
      </li>
      <li>
        <button type="button" data-action="return">Return items</button>
      </li>
      <li>
        <button type="button" data-action="invoice">Download invoice</button>
      </li>
      <li>
        <button type="button" data-action="report">Report a problem</button>
      </li>
    </ul>
  </div>
</article>
```

```css
.actions { position: relative; }
.actions-menu { position: absolute; top: 100%; left: 0; min-width: 12rem; margin: 0; padding: 0.25rem; list-style: none; background: #fff; border: 1px solid #d4d4d8; border-radius: 0.5rem; }
.actions-menu button { display: block; width: 100%; padding: 0.5rem 0.75rem; border: 0; border-radius: 0.375rem; background: none; font: inherit; text-align: left; cursor: pointer; }
.actions-menu button:is(:hover, :focus) { background: #f4f4f5; }
.actions-menu button:focus-visible { outline: 2px solid #2563eb; outline-offset: -2px; }
```

```js
const actions = document.querySelector('.actions');
const button = actions.querySelector('.actions-button');
const menu = document.getElementById(button.getAttribute('aria-controls'));
function setOpen(open) {
  menu.hidden = !open;
  button.setAttribute('aria-expanded', String(open));
}
// The list follows the button in the page, so the next Tab goes into it.
button.addEventListener('click', () => setOpen(menu.hidden));
menu.addEventListener('click', (event) => {
  const item = event.target.closest('[data-action]');
  if (!item) return;
  setOpen(false);
  button.focus();
  runAction(item.dataset.action);
});
// Escape closes the list and goes back to its button.
actions.addEventListener('keydown', (event) => {
  if (event.key === 'Escape' && !menu.hidden) {
    setOpen(false);
    button.focus();
  }
});
// Focus or a click anywhere else closes it.
for (const type of ['focusin', 'click']) {
  document.addEventListener(type, (event) => {
    if (!actions.contains(event.target)) setOpen(false);
  });
}
```

The menu roles are gone, and the actions are ordinary buttons in a list. With `aria-expanded`, a screen reader says something like "Actions, button, collapsed", and "expanded" once it is pressed. Focus stays on Actions, but the list comes straight after it in the page, so the next Tab goes to Buy again, and some screen readers announce a list of 4 items as it goes in. Each action is a Tab stop, run with Enter or Space, and Tab after the last one moves on through the page, closing the list behind it. This works only while the list follows the button in the markup: a list rendered at the end of the page is reached last.

## Which option to use

Both pass. The disclosure is the smaller change, and it suits a few actions on a page of a site, like this one: its actions are buttons that every browser, screen reader and voice control program already knows, it needs one attribute and a toggle, and people move through it with Tab as they do through the rest of the page. Build the menu button where the page works like an application, such as a mail client, a file manager or an editor, where people expect actions to behave like the menus of their operating system, and where each action as a Tab stop would make every row slow to pass. Then it needs all of it: the roles, the open state, focus moved in, and every key. The ARIA Authoring Practices' Actions menu example is the model. If the card has room, consider not hiding the actions at all: a button for each one, as [right-click, long-press and swipe actions with no button](/learn/context-menus) shows.

## Implementation decisions

On a page of orders, every card's button says Actions. Keep the word on the button and add which order it is for, with `aria-describedby` pointing at the card's heading, or hidden text after the word (`Actions <span class="visually-hidden">for order 1042</span>`), so the name still starts with what is shown, as [buttons and links named differently from their text](/learn/label-in-name) explains. The menu takes its name from the button through `aria-labelledby`.

The menu button pattern allows a few more things. Down Arrow on the button may open the menu on its first item, and Up Arrow on its last. A letter may move to the next item that starts with it, which matters in long menus. An action that is unavailable stays in the menu with `aria-disabled="true"`, so people learn it exists, and can still be reached with the arrow keys. An action that switches something on and off is a `menuitemcheckbox` with `aria-checked`. Keep submenus out of action menus: each one adds keys to get right, Right Arrow to open it and Left Arrow to leave it, and a menu to place on screen.

A `role="menu"` holds menu items and nothing else: no links, fields or headings beside them. If a menu needs those, it is a disclosure or a dialog. Links to other pages, such as View invoice, can sit in the disclosure's list as links; in a menu they need `role="menuitem"` too.

The `popover` attribute can carry either menu. The browser then closes it on Escape and on a click outside, puts focus back on the button after Escape, and draws it above the page, where no `overflow: hidden` on a card cuts it off. The menu in [right-click, long-press and swipe actions with no button](/learn/context-menus) is built that way. A popover is centred on the screen until you place it, with CSS anchor positioning or a few lines of script. Without it, check that the card doesn't clip the menu. If the menu is rendered at the end of the page, as some component libraries do, focus moved into it by script still reaches it, but a disclosure list there is reached only after everything else, as [floating buttons and panels that keyboard focus reaches last](/learn/floating-order) explains. Where focus goes when either one closes is covered in [search panels that close on Tab and drop keyboard focus](/learn/panel-dismissal).

Menu components in libraries vary: some move focus and handle every key, some only add the roles. Check the one you use with the keyboard before relying on it.

## Verify the fix

1. Put the mouse aside and Tab to Actions. Open it with Enter, and again with Space; reach every action, run Download invoice, and check that focus is back on Actions. In the disclosure, the first Tab after Actions goes to Buy again.
2. With a screen reader, move to Actions: it says the button opens a menu, or that it is collapsed. Open and close it, and check that the state it reads follows the menu.
3. Open the menu with a screen reader. In the menu button, the menu and its first item are read as focus moves in, usually with the item's place, 1 of 4; in the disclosure, the button says expanded, and the next Tab reads its first button, with the list in some screen readers.
4. In the menu button, check every key against the pattern: Down and Up Arrow move and wrap round, Home and End go to the first and last action, Escape closes the menu on Actions, and Tab closes it and moves on. No item is a Tab stop of its own.
5. Turn on forced colors (Windows Contrast themes) and zoom to 200%. The menu's edge, the focused action and the focus ring are all visible, and no action is cut off by the card or the window.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the menu, its component or its library changes.

## Limits

This is a starting pattern, not a guarantee for every application. The scripts are minimal sketches: one menu per page, no type-ahead, no unavailable items, no submenus. A drop-down of links across the top of a site is navigation, a different pattern, covered in [hover-only dropdowns, or ones that never say they are open](/learn/nav-menus); a drop-down that picks a value, such as a size, is a select, covered in [custom dropdowns that keyboards and screen readers can't use](/learn/custom-select). Menus that only a right-click or a long press opens are covered in [right-click, long-press and swipe actions with no button](/learn/context-menus), and a button that is only an icon, such as three dots, also needs a name, as [icon-only buttons with no accessible name](/learn/icon-buttons) explains. Test the complete journey with your target assistive technology.

## What screen readers do (tested by hand by EaseWeb)

`aria-expanded`: Says whether a button’s menu or section is open.

| Screen reader | Browser and system | Press it | Tab to it |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

`aria-haspopup="menu"`: Says that a button opens a menu; ARIA also allows listbox, tree, grid and dialog.

| Screen reader | Browser and system | Tab to it |
| --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested |
| NVDA | Chrome, Windows 11 | Not tested |
| JAWS | Edge, Windows 11 | Not tested |
| NVDA | Firefox, Windows 11 | Not tested |
| NVDA | Edge, Windows 11 | Not tested |
| JAWS | Firefox, Windows 11 | Not tested |
| VoiceOver | Safari, macOS | Not tested |
| VoiceOver | Safari, iOS | Not tested |
| TalkBack | Chrome, Android | Not tested |

https://easeweb.dev/learn/screen-reader-support

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
