Row Action Menus With the Menu Button Pattern
Permalink to "Row Action Menus With the Menu Button Pattern"A row action menu is the “⋯” or kebab button at the end of a table row that opens a short list of actions for that record: Edit, Duplicate, Export, Delete. It saves horizontal space and keeps rows tidy. Built from a <div> popup with clickable items, it is invisible to keyboard users and silent for screen reader users. Built from the ARIA menu button pattern, it is one tab stop per row with a predictable keyboard model and a clear announcement of what it opens.
This page implements the pattern for data tables, including the part most examples skip: where focus goes after an item runs and the row changes. It belongs to row actions & context menus.
Spec reference
Permalink to "Spec reference"The ARIA menu button pattern combines:
- A
<button>witharia-haspopup="menu"(ortrue) andaria-expanded="true|false", and optionallyaria-controlspointing at the menu. - A
role="menu"container, named by the button viaaria-labelledby, containingrole="menuitem"(ormenuitemcheckbox/menuitemradio) children. - Focus moves into the menu on open.
Up/Down Arrowmove between items (wrapping is optional),Home/Endgo to the ends, printable characters jump to the next item starting with that letter,Enter/Spaceactivate,Escapecloses and returns focus to the button,Tabcloses and moves on.
Menu items use roving tabindex (the focused item 0, others -1) or keep focus on the menu and use aria-activedescendant. Only menu item roles may be children of a menu, so separators use role="separator" and groups role="group".
Criteria: SC 4.1.2 Name, Role, Value; SC 2.1.1 Keyboard; SC 2.4.3 Focus Order (focus return); SC 1.3.1 for the menu’s relationship to its button.
When to use a menu — and when visible buttons
Permalink to "When to use a menu — and when visible buttons"Use a menu when a row has more than two or three actions, or when actions are secondary. It keeps each row to one tab stop for actions, which matters in a long table: forty rows with four visible buttons each is 160 tab stops.
Use visible buttons for the one or two primary actions users take on most rows — Open, Approve — and put the rest in the menu. Hiding the primary action behind a menu costs every user two extra keypresses per row.
The misapplication to name is using role="menu" for a disclosure of links, such as a navigation dropdown. Menus switch screen readers into application-like handling and expect arrow-key navigation; a list of links in a disclosure is simpler and behaves as users expect for navigation.
Annotated code example
Permalink to "Annotated code example"<td class="row-actions">
<!-- SC 4.1.2: named with the record; announces as a menu button -->
<button type="button" id="act-1042" aria-haspopup="menu" aria-expanded="false"
aria-controls="menu-1042">
<span aria-hidden="true">⋯</span>
<span class="visually-hidden">Actions for INV-1042</span>
</button>
<ul role="menu" id="menu-1042" aria-labelledby="act-1042" hidden>
<li role="menuitem" tabindex="-1" data-action="edit">Edit</li>
<li role="menuitem" tabindex="-1" data-action="duplicate">Duplicate</li>
<li role="menuitem" tabindex="-1" data-action="export">Export as PDF</li>
<li role="separator"></li>
<li role="menuitem" tabindex="-1" data-action="delete" class="danger">Delete…</li>
</ul>
</td>
function openMenu(btn, focusLast = false) {
const menu = document.getElementById(btn.getAttribute('aria-controls'));
btn.setAttribute('aria-expanded', 'true');
menu.hidden = false;
const items = [...menu.querySelectorAll('[role="menuitem"]')];
(focusLast ? items.at(-1) : items[0]).focus(); // SC 2.4.3: focus into the menu
}
function closeMenu(btn, { returnFocus = true } = {}) {
btn.setAttribute('aria-expanded', 'false');
document.getElementById(btn.getAttribute('aria-controls')).hidden = true;
if (returnFocus) btn.focus();
}
document.addEventListener('keydown', (e) => {
const item = e.target.closest('[role="menuitem"]');
if (!item) return;
const menu = item.closest('[role="menu"]');
const btn = document.getElementById(menu.getAttribute('aria-labelledby'));
const items = [...menu.querySelectorAll('[role="menuitem"]')];
const i = items.indexOf(item);
const go = (j) => items[(j + items.length) % items.length].focus();
switch (e.key) {
case 'ArrowDown': e.preventDefault(); go(i + 1); break;
case 'ArrowUp': e.preventDefault(); go(i - 1); break;
case 'Home': e.preventDefault(); go(0); break;
case 'End': e.preventDefault(); go(items.length - 1); break;
case 'Escape': e.preventDefault(); closeMenu(btn); break; // back to trigger
case 'Tab': closeMenu(btn, { returnFocus: false }); break; // let Tab move on
case 'Enter': case ' ':
e.preventDefault();
closeMenu(btn, { returnFocus: true });
runAction(item.dataset.action, btn); // may move focus elsewhere
break;
default: // type-ahead
if (e.key.length === 1) {
const next = items.slice(i + 1).concat(items.slice(0, i + 1))
.find((it) => it.textContent.trim().toLowerCase().startsWith(e.key.toLowerCase()));
next?.focus();
}
}
});
Returning focus to the trigger before running the action is deliberate. Actions that open a dialog (Edit, Delete…) move focus into the dialog, and the dialog returns it to the trigger when it closes — a consistent chain. Actions that remove the row need the substitute logic from preserving focus when rows are deleted.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | Expected behaviour | Announcement |
|---|---|---|
Tab to trigger |
Focus on the button | “Actions for INV-1042, menu button, collapsed” |
Enter / Space / Down Arrow |
Opens; focus on first item | “Edit, menu item, 1 of 4” |
Up Arrow on trigger |
Opens; focus on last item | “Delete…, menu item, 4 of 4” |
Down Arrow |
Next item | “Duplicate, 2 of 4” |
D |
Next item starting with D | “Delete…, 4 of 4” |
Escape |
Closes; focus on trigger | “Actions for INV-1042, menu button, collapsed” |
Integration context
Permalink to "Integration context"The trigger’s name follows naming icon-only row action buttons: the menu items can then be short, because the menu is named “Actions for INV-1042” through aria-labelledby. A right-click context menu on the same row can reuse the same menu element, opened from the keyboard as described in opening context menus from the keyboard.
Destructive items end with an ellipsis when they open a confirmation, and their full behaviour is in confirming and undoing destructive row actions.
Gotchas
Permalink to "Gotchas"Menus clipped by the table. A menu rendered inside a cell with overflow: hidden is cut off. Render it in a portal positioned next to the trigger, but keep aria-controls and focus handling pointed at it.
One menu element per row. Forty hidden menus add DOM weight. Render a single menu and move it to the active row on open, updating aria-labelledby and id references.
Click outside. Close the menu when focus or a pointer leaves it, without moving focus back to the trigger — the user has chosen to go elsewhere.
Design system notes
Permalink to "Design system notes"Provide a RowActions component that takes the row label and an array of actions, and renders primary actions as visible buttons and the rest in a menu button. It owns naming (“Actions for …”), the keyboard model, portal positioning and focus return, and accepts a focusAfter callback per action for the row-removing cases.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"What ARIA pattern should a row actions "⋯" button use?
The menu button pattern: a button with aria-haspopup=“menu” and aria-expanded that opens a role=“menu” of menuitems. Focus moves into the menu, arrow keys move between items, and Escape returns focus to the button.
Should the menu items include the record name?
Not if the menu is labelled by its trigger. With aria-labelledby pointing at a trigger named “Actions for INV-1042”, screen readers already have the context, and items can be short like “Edit” and “Delete”.
Where should focus go after choosing a menu item?
Back to the trigger by default, or into whatever the item opened, such as a dialog. If the action removes the row, move focus to the same trigger in the next row.
When should I use visible buttons instead of a menu?
For the one or two actions users take on most rows. Hiding a primary action in a menu costs every user extra keypresses on every row.
Related
Permalink to "Related"- Opening context menus from the keyboard — right-click equivalents
- Naming icon-only row buttons — the trigger’s name
- Confirming and undoing destructive actions — what Delete does next