Opening Custom Context Menus From the Keyboard

Permalink to "Opening Custom Context Menus From the Keyboard"

A custom context menu replaces the browser’s right-click menu with application actions: copy cell, filter by this value, hide column, open record. In spreadsheet-like grids it is a natural fit. The accessibility failure is building it on mousedown with button === 2, so it exists only for mouse users — while keyboard users have two standard keys for exactly this purpose, Shift+F10 and the Menu key, that do nothing.

This page wires the context menu to the event that covers every input method, positions it where keyboard users expect, and keeps a visible alternative. It belongs to row actions & context menus.

Spec reference

Permalink to "Spec reference"

The contextmenu event (UI Events / HTML) fires on the focused element for the Menu key (the “Applications” key on Windows keyboards) and for Shift+F10, and on the element under the pointer for a right-click. Calling preventDefault() suppresses the browser’s own menu. For keyboard-triggered events, the event’s clientX/clientY are typically 0 or the element’s position, depending on the browser — you cannot rely on them.

On macOS, Ctrl+click produces a contextmenu event; there is no Menu key, and VoiceOver users open context menus with VO+Shift+M, which also fires the event on the VoiceOver cursor’s element.

The menu itself follows the ARIA menu pattern: role="menu", role="menuitem", arrow-key navigation, Escape to close. SC 2.1.1 Keyboard requires that the menu’s functions are available by keyboard; SC 2.4.3 Focus Order governs the return of focus.

What fires contextmenu, by platform and input Matrix of inputs that open a context menu on Windows, macOS and with screen readers, and whether each fires the contextmenu event. What fires contextmenu, by platform and inputInputWindowsmacOSFires contextmenu?Right-clickYesYesYesShift+F10YesNot standardYes (Windows)Menu keyYesNo keyYesCtrl+click—YesYesVoiceOver VO+Shift+M—YesYes
Listening for contextmenu — not for right mouse button — covers every row of this table.

When to use a custom context menu — and when not to

Permalink to "When to use a custom context menu — and when not to"

Use one in grids where cell-specific commands are frequent and the pointer is the dominant input: spreadsheet editors, pivot explorers, file managers. Keyboard users benefit too, once the keys work.

Do not make it the only way to reach an action. Context menus are hidden by nature — nothing on screen says they exist — so every item should also be reachable from a visible control: a row action menu button, a toolbar, or the command palette. Touch users need that visible route as well, since long-press behaviour varies.

Do not override the browser’s context menu on text content that users want to copy, translate or look up. Limit the custom menu to the grid.

The misapplication to name is a mousedown handler checking e.button === 2. It never fires for Shift+F10, the Menu key or VoiceOver’s context menu command.

Annotated code example

Permalink to "Annotated code example"
grid.addEventListener('contextmenu', (e) => {
  const cell = e.target.closest('[role="gridcell"], [role="columnheader"]');
  if (!cell) return;                                   // leave the browser menu elsewhere
  e.preventDefault();

  // Keyboard-triggered? Anchor to the focused cell instead of the pointer
  const fromKeyboard = e.button !== 2 || (e.clientX === 0 && e.clientY === 0);
  const target = fromKeyboard ? document.activeElement.closest('[role="gridcell"], [role="columnheader"]') ?? cell : cell;
  if (!fromKeyboard) target.focus();                   // right-click also moves focus there

  const rect = target.getBoundingClientRect();
  const pos = fromKeyboard
    ? { x: rect.left + 8, y: rect.bottom + 4 }         // just below the focused cell
    : { x: e.clientX, y: e.clientY };

  openContextMenu(target, pos);                        // SC 2.1.1
});

function openContextMenu(cell, { x, y }) {
  const menu = document.getElementById('cell-menu');
  menu.dataset.for = cell.id;
  menu.setAttribute('aria-label', `Actions for ${cellLabel(cell)}`);   // "Actions for Northwind, Amount"
  Object.assign(menu.style, { left: `${x}px`, top: `${y}px` });
  menu.hidden = false;
  menu.querySelector('[role="menuitem"]').focus();     // SC 2.4.3: into the menu
}

function closeContextMenu() {
  const menu = document.getElementById('cell-menu');
  menu.hidden = true;
  document.getElementById(menu.dataset.for)?.focus();  // back to the cell
}
<ul role="menu" id="cell-menu" hidden>
  <li role="menuitem" tabindex="-1" data-action="copy">Copy cell <kbd>Ctrl+C</kbd></li>
  <li role="menuitem" tabindex="-1" data-action="filter">Filter by this value</li>
  <li role="menuitem" tabindex="-1" data-action="hide-col">Hide column</li>
  <li role="menuitem" tabindex="-1" data-action="open">Open record</li>
</ul>

Naming the menu after the cell (“Actions for Northwind, Amount”) gives screen reader users the context that a sighted user gets from the menu’s position on screen.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key / event Expected behaviour Announcement
Shift+F10 on a cell Menu opens below the cell; first item focused “Actions for Northwind, Amount, menu. Copy cell, menu item, 1 of 4”
Menu key Same Same
VoiceOver VO+Shift+M Menu opens at the VoiceOver cursor’s cell Same, from VoiceOver
Arrows, Home, End Move between items Item and position
Escape Close; focus back on the cell Cell re-read
Enter on “Hide column” Column hidden; focus to nearest visible cell “Amount column hidden.”
Keyboard context menu, open to action Timeline of a keyboard user opening a custom context menu with Shift+F10 on a grid cell, choosing Filter by this value, and returning to the grid. Keyboard context menu, open to actionFocus on cell"Northwind, Customer"Shift+F10menu below the cellDown Arrow"Filter by this value"Enterfilter appliedBack in grid"12 rows shown."one context menu command
The menu appears where the user's focus is, and focus returns there when it closes.

Integration context

Permalink to "Integration context"

Every item in the context menu should also exist in a visible route — typically the row’s menu button from row action menus with the menu button pattern for record actions, and the column header menu for column actions. List Shift+F10 in the grid’s help dialog; many keyboard users do not know it, and even fewer know a web page supports it — see keyboard shortcut help dialogs.

The focused cell comes from the grid’s roving focus model, described in implementing roving tabindex for custom data grids.

Listening for mouse buttons versus contextmenu Comparison of opening a custom context menu on mousedown with the right button against opening it on the contextmenu event. Listening for mouse buttons versus contextmenu✗ mousedown, button 2Right-click onlyShift+F10 opens the browser menu insteadMenu key does nothing usefulVoiceOver's context menu command ignored✓ contextmenu eventRight-click, Shift+F10, Menu keyCtrl+click on macOSVoiceOver VO+Shift+MOne handler, positioned by input type
One listener change turns a mouse-only feature into one every input method can reach.

Gotchas

Permalink to "Gotchas"

Positioning off-screen. A menu anchored below a cell near the bottom of the viewport opens off-screen. Flip it above the cell when there is no room.

Browser menu on second press. Some users press Shift+F10 twice expecting the browser menu. Consider letting a second press within the open menu close yours and fall through.

Focus mode. In NVDA browse mode, Shift+F10 goes to the element under the virtual cursor, which may not be the cell with DOM focus. Grids put NVDA in focus mode, which keeps the two aligned.

Design system notes

Permalink to "Design system notes"

A grid component should expose a cellActions(cell) hook that returns the items for both the context menu and the visible menus, so the two cannot diverge. The component owns the contextmenu listener, keyboard positioning, naming and focus return; products supply only the action list.

Testing checklist

Permalink to "Testing checklist"

FAQ

Permalink to "FAQ"
How do keyboard users open a context menu?

With Shift+F10 or the Menu key on Windows, and with VO+Shift+M in VoiceOver on macOS. All of these fire the contextmenu event, so a custom context menu should listen for that event rather than for the right mouse button.

Where should a keyboard-opened context menu appear?

Next to the focused element, usually just below the focused cell. Keyboard-triggered contextmenu events do not carry a useful pointer position.

Can a context menu be the only way to perform an action?

It should not be. Context menus are invisible until opened, and touch support varies. Provide the same actions through a visible control such as a row actions menu button.

Should a custom context menu replace the browser menu everywhere?

No. Limit it to the grid, and leave the browser’s menu on ordinary text so users keep copy, translate and look-up features.

Permalink to "Related"

← Back to Row Actions & Context Menus