Skip to content

Context Menu

NavigationSince 0.3.0
import { ContextMenu } from '@absoluteui/components/context-menu';
import '@absoluteui/components/context-menu/style.css';

Using Angular? See Context Menu for Angular.

A Menu opened as a context menu: on the contextmenu event at the pointer position, or from the keyboard with Shift+F10 or the ContextMenu key at the focused element inside the target. It reuses the Menu engine (WAI-ARIA APG Menu), so submenus, checkbox and radio items, typeahead, and the overlay stack behave exactly as in Menu. The browser’s native menu stays reachable: Shift+right-click is left to the browser by default (nativeModifier: 'shift'), and disabled keeps the native menu entirely.

  • Shortcuts to commands for an object under the pointer (rows, files, canvas objects) that are also available elsewhere in the UI.
  • Per-row commands in a list or table, using the origin in the select detail to find the row.
  • As the only way to reach an action: context menus are hard to discover and unavailable on many touch devices; always provide a visible control (for example a Menu button) too.
  • Replacing the browser menu on text content where users expect copy, paste, spell check, or ‘open link in new tab’.
  • Choosing a form value: use Select, Combobox, or Radio Group.
  • Rich interactive content: use Popover or Dialog.

One context menu for a list; find the row from origin

Section titled “One context menu for a list; find the row from origin”
import { ContextMenu, type ContextMenuSelectDetail } from '@absoluteui/components/context-menu';
const list = document.querySelector<HTMLUListElement>('#files');
if (list) {
// The target gets no ARIA attributes: describe the menu so it is discoverable.
const hint = document.createElement('p');
hint.id = 'files-hint';
hint.textContent = 'Right-click a file or press Shift+F10 for actions. The same actions are in each row\'s Actions button.';
list.before(hint);
list.setAttribute('aria-describedby', hint.id);
ContextMenu.create(list, {
label: 'File actions',
items: [
{ id: 'rename', label: 'Rename' },
{ id: 'share', label: 'Share', items: [{ id: 'copy-link', label: 'Copy link' }] },
{ type: 'separator' },
{ id: 'delete', label: 'Delete', tone: 'danger' },
],
});
list.addEventListener('aui-select', (event) => {
const { id, origin } = (event as CustomEvent<ContextMenuSelectDetail>).detail;
const row = origin.closest<HTMLLIElement>('li[data-file]');
if (row === null) return;
row.dataset.lastAction = id;
});
}

Different items per origin with update() from a capture-phase listener

Section titled “Different items per origin with update() from a capture-phase listener”
import { ContextMenu, type MenuItem } from '@absoluteui/components/context-menu';
import { Button } from '@absoluteui/components/button';
const canvas = document.createElement('div');
canvas.tabIndex = 0;
canvas.setAttribute('aria-label', 'Drawing');
const shape = document.createElement('button');
shape.type = 'button';
shape.dataset.shape = 'circle';
shape.textContent = 'Circle';
Button.enhance(shape, { variant: 'outline' });
canvas.append(shape);
document.body.append(canvas);
const shapeItems: MenuItem[] = [
{ id: 'bring-front', label: 'Bring to front' },
{ id: 'delete-shape', label: 'Delete shape', tone: 'danger' },
];
const canvasItems: MenuItem[] = [
{ id: 'paste', label: 'Paste', shortcut: 'Ctrl+V' },
{ type: 'checkbox', id: 'snap', label: 'Snap to grid', checked: true },
];
const menu = ContextMenu.create(canvas, { items: canvasItems });
// Capture phase runs before the component's own listener, so the right items are in place when it opens.
const chooseItems = (event: Event): void => {
const onShape = event.target instanceof Element && event.target.closest('[data-shape]') !== null;
menu.update({ items: onShape ? shapeItems : canvasItems });
};
canvas.addEventListener('contextmenu', chooseItems, { capture: true });
canvas.addEventListener('keydown', (event) => {
if (event.key === 'ContextMenu' || (event.key === 'F10' && event.shiftKey)) chooseItems(event);
}, { capture: true });
import { configureAbsoluteUI } from '@absoluteui/core';
configureAbsoluteUI({ components: { contextMenu: { nativeModifier: 'none', closeOnSelect: false } } });
import { ContextMenu, type ContextMenuSelectDetail } from '@absoluteui/components/context-menu';
import '@absoluteui/components/menu/style.css';
import '@absoluteui/components/context-menu/style.css';
const table = document.querySelector<HTMLTableElement>('#orders');
if (table) {
const menu = ContextMenu.create(table, {
label: 'Order actions',
items: [
{ id: 'open', label: 'Open' },
{ id: 'copy-id', label: 'Copy order id', shortcut: 'Ctrl+C' },
{ type: 'separator' },
{ id: 'cancel', label: 'Cancel order', tone: 'danger' },
],
});
table.addEventListener('aui-select', (event) => {
const { id, origin } = (event as CustomEvent<ContextMenuSelectDetail>).detail;
const row = origin.closest<HTMLTableRowElement>('tr[data-order]');
if (row) row.dataset.lastAction = id;
});
menu.element.addEventListener('aui-close', () => {
table.dataset.menu = 'closed';
});
}
import { ContextMenu, type ContextMenuSelectDetail } from '@absoluteui/components/context-menu';
const list = document.querySelector<HTMLElement>('#files');
if (list) {
ContextMenu.create(list, { items: [{ id: 'rename', label: 'Rename' }, { id: 'delete', label: 'Delete', tone: 'danger' }] });
list.addEventListener('aui-select', (event) => {
const { id, origin } = (event as CustomEvent<ContextMenuSelectDetail>).detail;
const row = origin.closest<HTMLElement>('[data-file]');
if (row) row.dataset.lastAction = id;
});
}

To vary items per origin, call update({ items }) from a capture-phase contextmenu (and keydown) listener on the target: it runs before the component’s own listener. More examples, type-checked in CI: metadata/context-menu/examples.json.

<div id="files"> (consumer target; no attributes added)
<li data-file="a.txt">...</li> (right-clicked or focused element = origin)
</div>
...
<div class="aui-menu aui-menu--context" role="menu" popover="manual" aria-label="Context menu"> (end of the target's <dialog>, or <body>)
<div class="aui-menu__item" role="menuitem" tabindex="-1">...</div> (Menu items, roving tabindex; see Menu)
</div>
<div class="aui-menu aui-menu--submenu" role="menu" popover="manual"> (one per submenu)
ContextMenu.create(target: HTMLElement, options: ContextMenuOptions): ContextMenu

Global configuration key: contextMenu (see Configuration).

Option Type Default Description
items readonly MenuItem[] Menu items (the Menu item model: action with optional submenu items, checkbox, radio, separator, group). Ids are unique across the whole tree. An empty array is allowed. Required.
label string aria-label of the menu. Default: localized ‘Context menu’. Must be non-empty when set.
disabled boolean false While true, right-click and the keyboard keys keep the browser’s native behavior and open() is still possible; setting it closes an open menu.
renderItem (item: MenuSelectableItem, context: MenuRenderContext) => Node Custom item content (since 0.9.0); the label stays the accessible name and typeahead text. Nodes are reused per item and released with releaseNode when dropped.
icons Partial<{ submenu: IconRef; checked: IconRef; radio: IconRef }> Icon slot overrides.
closeOnSelect boolean true Close after activating a checkbox or radio item. Action items always close the menu. Globally configurable.
typeahead boolean true Printable characters move focus to the next item whose label starts with them. Globally configurable.
nativeModifier 'shift' | 'none' "shift" ‘shift’: Shift+right-click is left to the browser’s native menu. ‘none’: every right-click opens the component menu. Globally configurable.
direction 'ltr' | 'rtl' "ltr" Logical alignment, submenu side, submenu arrow keys, and chevron mirroring. Globally configurable. Inherited from common configuration.
Property Type Description
target HTMLElement The element whose content opens the menu. It receives no ARIA attributes.
element HTMLDivElement Top-level role=menu popup (popover=manual, class aui-menu aui-menu–context), appended to the target’s enclosing <dialog> or <body>.
isOpen boolean Whether the menu is open.
origin Element | null Element the open (or last opened) menu belongs to: the right-clicked element, the focused element, or the target for open(). Null before the first open and after destroy().
options Readonly<ContextMenuOptions> Current options.
Method Description
open(point?: { x: number; y: number }): void Open at viewport (client) coordinates, or below the target when no point is given; focus moves to the first enabled item and origin becomes the target. Non-finite coordinates throw CONTEXT_MENU_OPTIONS.
close(): void Close all levels; focus is restored only when it was inside the menu. No aui-select.
update(options: Partial<ContextMenuOptions>): void Merge, validate, and apply options; new items reset checkbox/radio state and close open submenus. A failed update keeps the previous options.
destroy(): void Close, remove all popups, listeners, and subscriptions; the target keeps the native menu again.
Event Detail Bubbles Cancelable Description
aui-select { id: string; item: MenuSelectableItem; checked?: boolean; origin: Element } Yes No Dispatched on the target when the user activates an enabled item. origin is the element the menu was opened for; checked is the new state for checkbox and radio items.
aui-open null No No Dispatched on the menu element after the top level opened.
aui-close null No No Dispatched on the menu element after all levels closed.

All rules live in the aui.components cascade layer, so application styles override them without specificity battles.

Class Purpose
aui-menu--context Added to the top-level popup; sets a 12rem minimum inline size.
aui-menu (and all aui-menu__* classes) Menu popup and items; styled by @absoluteui/components/menu/style.css.
Slot Default icon
submenu chevron-right
checked check
radio check
type ContextMenuNativeModifier = 'shift' | 'none';
interface ContextMenuConfig { closeOnSelect: boolean; typeahead: boolean; nativeModifier: ContextMenuNativeModifier }
interface ContextMenuOptions extends Partial<ContextMenuConfig> {
items: readonly MenuItem[];
label?: string;
disabled?: boolean;
direction?: 'ltr' | 'rtl';
icons?: Partial<MenuIcons>;
}
interface ContextMenuSelectDetail extends MenuSelectDetail { origin: Element }

MenuItem, MenuSelectableItem, MenuSelectDetail, and MenuIcons are re-exported from @absoluteui/components/context-menu.

Pattern: WAI-ARIA APG Menu (the Menu engine) opened as a context menu at the pointer or at the focused element; roving focus with real DOM focus on items.

Key Context Action
Shift+F10 / ContextMenu Focus inside the target, not disabled Opens the menu at the focused element and focuses the first enabled item.
ArrowDown / ArrowUp Menu Next / previous item, wrapping. Disabled items receive focus.
Home / End Menu First / last item.
Printable characters Menu, typeahead=true Focus the next item whose label starts with the typed characters.
Enter Item focused Activates the item (closes, restores focus, emits aui-select), toggles a checkbox or checks a radio, or opens a submenu.
Space Item focused Same as Enter; with closeOnSelect=false, toggles checkbox/radio items without closing.
ArrowRight (ArrowLeft in RTL) Submenu parent focused Opens the submenu and focuses its first item.
ArrowLeft (ArrowRight in RTL) Submenu Closes the submenu and returns focus to its parent item.
Escape Submenu Closes that submenu only and returns focus to its parent item.
Escape Top-level menu Closes the menu and restores focus to the previously focused element; an enclosing Dialog stays open.
Tab / Shift+Tab Menu Closes all levels, restores focus, and lets the browser move focus on from there.
Element Role
target unchanged (no role or ARIA attributes are added)
popup and submenus menu
items menuitem, menuitemcheckbox, or menuitemradio
group group
separator separator
icons none (aria-hidden=true)
Attribute Element Value
aria-label top-level menu The label option, default localized ‘Context menu’
aria-haspopup submenu parent items “menu”
aria-expanded submenu parent items “true” while the submenu is open
aria-controls submenu parent items id of the submenu
aria-labelledby submenu Label of its parent item
aria-labelledby items The item’s label text
aria-describedby items Description and shortcut hint ids
aria-disabled disabled items “true”; the item stays focusable but is not activatable
aria-checked checkbox and radio items “true” | “false”

Opening moves real DOM focus to the first enabled item. Activation, Escape at the top level, Tab, and close() with focus inside restore focus to the element focused before opening (not the body), else to the target when it is focusable. Outside presses and focus moving elsewhere close without moving focus.

Criterion Level Note
1.3.1 Info and Relationships A
1.4.3 Contrast (Minimum) AA
1.4.11 Non-text Contrast AA
2.1.1 Keyboard A
2.1.2 No Keyboard Trap A
2.4.3 Focus Order A
2.4.7 Focus Visible AA
4.1.2 Name, Role, Value A

Automated test results: Accessibility and compliance.

  • The target receives no ARIA attributes, so nothing announces the context menu: describe it (for example aria-describedby text ‘Press Shift+F10 for actions’) or show a visible hint.
  • Never put actions only in a context menu; offer each one through a visible control as well (2.1.1, discoverability).
  • Keyboard access uses Shift+F10 and the ContextMenu key at the focused element, so descendants must be focusable for per-row keyboard use.
  • Popups are appended to the enclosing <dialog> so they are not inert inside a modal Dialog; each level is a separate overlay-stack entry.
  • Right-clicking inside the open popup never shows the native menu over it.
  • Manual screen-reader verification (NVDA, JAWS, VoiceOver) is pending.
  • Inside an enabled target the native browser menu is not reachable from the keyboard (Shift+F10 and the ContextMenu key always open the component menu).

closed, open at the pointer, open at an element (keyboard or open() without a point), submenu open, disabled (native menu kept), item focused, item disabled, item checked.

From Trigger To
closed right-click inside the target (not Shift with nativeModifier: 'shift', not disabled) open at the pointer; native menu suppressed; first enabled item focused; aui-open
closed Shift+F10 or ContextMenu key with focus inside the target (not disabled) open below the focused element
closed open(point?) open at the point, or below the target
open right-click inside the target again closed and reopened at the new point and origin
open activate an action item closed; focus restored; aui-close, then aui-select
open activate a checkbox/radio item state toggled; closed only when closeOnSelect is true
open Escape at the top level closed; focus restored
open Tab, outside press, focus moving elsewhere, close(), update({ disabled: true }) closed
  • Nothing announces that a context menu exists. Add a visible hint or a description, for example aria-describedby text “Press Shift+F10 for actions”, on the target or its rows.
  • Never put actions only in a context menu; offer each one through a visible control as well.
  • For per-row keyboard use, rows (or controls in them) must be focusable: the keyboard opens the menu at the focused element.
  • Focus is restored to the element focused before opening (not <body>), else to the target when it is focusable.
  • Inside a modal Dialog the popup is appended to the dialog, so it is not inert; Escape closes the menu before the dialog.
  • Manual screen-reader verification is pending (see Known limitations).

The popup is at least 12rem wide (Menu maximum min(22rem, 100vw - 1rem)). Its height is capped by popover-max-height and the space available in the viewport, then it scrolls. The positioning engine flips and shifts every level to keep it on screen. At the pointer, the menu’s corner sits 1px from the pointer so the first item is not under it; the anchor moves with the target when the page scrolls.

With direction: 'rtl' (option or global direction), bottom-start alignment is mirrored, submenus open to the left, ArrowLeft opens a submenu and ArrowRight closes it, and the chevron is mirrored through [data-direction='rtl']. An inherited dir="rtl" alone does not switch the menu; set direction.

Identical to Menu: role tokens only, verified in the light, dark, and high-contrast themes; forced-colors mode draws the focused item in Highlight / HighlightText and disabled items in GrayText.

configureAbsoluteUI({ components: { contextMenu: { nativeModifier: 'none', closeOnSelect: false } } });

Changes apply to existing instances: direction updates immediately and an open menu is repositioned; nativeModifier, closeOnSelect, and typeahead apply to the next interaction. The global menu configuration does not apply.

Instance options override global and theme defaults. All options are dynamic.

icons.submenu (default chevron-right), icons.checked (default check), and icons.radio (default check). Item icons are set per item with icon.

All text uses textContent; nothing is parsed as HTML. Positioning writes styles through the CSSOM. CSP-safe. The native menu is suppressed only for right-clicks the component handles and inside its own popup.

No persistence, network access, or telemetry. Checkbox and radio state is kept in memory and reset by update({ items }).

All levels are built once per items change, detached, then appended. Two listeners on the target, one on the document, the Menu engine’s delegated listeners per level, and one configuration subscription. Budget: under 100 ms for ContextMenu.create plus open() with 200 items.

Tested with 200 items in one level and three nested levels. Use one instance per container (not per row) and identify the row from origin.

  • An empty items array is allowed; the menu opens with focus on the menu container.
  • Duplicate ids anywhere in the tree throw CONTEXT_MENU_OPTIONS.
  • Shift+right-click with nativeModifier: 'shift' is not handled at all (no preventDefault()), so the browser shows its native menu.
  • Right-clicking inside the open popup never shows the native menu.
  • A keyboard-generated contextmenu event (no pointer position) opens at the focused element inside the target, or below the target.
  • When the target is <body> the popup is inside the target; right-clicks inside the popup are ignored by the target handler.
  • open() while open closes and reopens at the new point.
Code Category Cause
CONTEXT_MENU_TARGET configuration The target is not a connected HTMLElement (elements from another realm fail the instanceof check).
CONTEXT_MENU_OPTIONS configuration items not an array, missing or empty label or id, duplicate id, empty submenu, missing radio group, non-boolean checked; empty label; non-boolean disabled; open() with non-finite coordinates.
CFG_INVALID_VALUE configuration Invalid closeOnSelect, typeahead, nativeModifier, or direction.

A failed construction leaves no popups, listeners, or subscriptions behind. A failed update() keeps the previous options.

  • The target gets no ARIA attributes; discoverability is the consumer’s job (hint or description).
  • nativeModifier applies to pointer right-clicks only. Inside an enabled target, Shift+F10 and the ContextMenu key always open the component menu, so the native menu is not reachable from the keyboard there.
  • One item list per instance; vary it with update({ items }).
  • The pointer anchor is fixed relative to the target’s box: scrolling content inside a scrollable target does not move the menu.
  • Keyboard-generated contextmenu events are recognized heuristically (not button 2, at client point 0,0).
  • Popups are placed in the target’s enclosing <dialog> or <body> at construction; moving the target between them needs destroy() and create().
  • The Menu limitations apply (150 ms submenu hover delay without a pointer-safe triangle; shortcut text is a hint only).
  • Screen-reader behavior has not yet been manually verified with NVDA, JAWS, and VoiceOver.
  • Opening on touch long-press, except where the browser itself fires contextmenu.
  • HTML or custom nodes inside items.
  • Link items (href); navigate in the aui-select listener.