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> with aria-haspopup="menu" (or true) and aria-expanded="true|false", and optionally aria-controls pointing at the menu.
  • A role="menu" container, named by the button via aria-labelledby, containing role="menuitem" (or menuitemcheckbox / menuitemradio) children.
  • Focus moves into the menu on open. Up/Down Arrow move between items (wrapping is optional), Home/End go to the ends, printable characters jump to the next item starting with that letter, Enter/Space activate, Escape closes and returns focus to the button, Tab closes 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.

The menu button lifecycle Vertical flow of a row action menu: the collapsed button, the open menu with focus on the first item, navigation between items, and activation with focus handoff. The menu button lifecycleCollapsed"Actions for INV-1042, menu button, collapsed"Tab stop in the rowOpenaria-expanded="true"; focus on first menuitemEnter, Space or Down ArrowNavigateUp, Down, Home, End, type-ahead between itemsEscape returns to buttonActivatemenu closes; action runs; focus to result or buttonTab closes and moves on
Four states, and every transition has a defined focus location.

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”
A div popup versus the menu button pattern Comparison of a row action popup built from clickable divs against one built with the ARIA menu button pattern. A div popup versus the menu button pattern✗ Clickable div popupTrigger announced as "button" or not at allItems not focusable; Tab skips themNo Escape, no focus returnPopup stays open when focus leaves✓ Menu button pattern"Actions for INV-1042, menu button"Arrow keys, Home, End, type-aheadEscape returns to the triggerCloses on Tab and outside click
Same visual design; one of them works from the keyboard and tells users what it is.

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.

A row with an open action menu Mock table row whose action menu is open, showing the trigger, the menu items and the separator before the destructive item. A row with an open action menuInvoiceAmountMenu itemStateINV-10421,280.00Edit1focusedDuplicateExport as PDFDelete…2after separator1Focus lands on the first menuitem whenthe menu opens2Destructive item last, separated, with anellipsis for its confirmation3Menu aria-labelledby the trigger: "Actionsfor INV-1042"
The menu is named by its trigger, so its items can stay short.

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.

Permalink to "Related"

← Back to Row Actions & Context Menus