Skip to content

Menu

OverlaysSince 0.2.0
import { Menu } from '@absoluteui/components/menu';
import '@absoluteui/components/menu/style.css';

Using Angular? See Menu for Angular.

An actions menu opened from a trigger button, following the WAI-ARIA APG Menu Button and Menu patterns. Menu supports icons, descriptions, shortcut hints, a danger tone, checkbox and radio items, labelled groups, separators, and nested submenus. Each level renders in the top layer through the Popover API and takes part in the shared overlay stack, so Escape and outside presses close only the topmost level, including inside a Dialog.

  • A list of commands or actions attached to a button (row actions, overflow ‘more’ menus, editor commands).
  • Toggling a few view options (checkbox items) or picking one of a few modes (radio items) inside an actions menu.
  • Grouping secondary actions into submenus to keep the first level short.
  • Choosing a form value: use Select, Combobox, or Radio Group.
  • Site or app navigation made of links: use navigation links or a disclosure menu, not role=menu.
  • A persistent horizontal command bar: use a toolbar.
  • Rich interactive content (forms, text fields): use a popover or dialog.

Row actions with a submenu and a destructive item

Section titled “Row actions with a submenu and a destructive item”
import { Menu, type MenuSelectDetail } from '@absoluteui/components/menu';
import { Button } from '@absoluteui/components/button';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.textContent = 'Actions';
Button.enhance(trigger);
document.body.append(trigger);
Menu.create(trigger, {
items: [
{ id: 'edit', label: 'Edit', icon: 'plus', shortcut: 'Ctrl+E' },
{ id: 'share', label: 'Share', items: [
{ id: 'email', label: 'Email' },
{ id: 'link', label: 'Copy link' },
] },
{ type: 'separator' },
{ id: 'delete', label: 'Delete', tone: 'danger' },
],
});
trigger.addEventListener('aui-select', (event) => {
const { id } = (event as CustomEvent<MenuSelectDetail>).detail;
trigger.dataset.lastAction = id;
});

Checkbox and radio items that keep the menu open

Section titled “Checkbox and radio items that keep the menu open”
import { Menu, type MenuSelectDetail } from '@absoluteui/components/menu';
import { Button } from '@absoluteui/components/button';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.textContent = 'View';
Button.enhance(trigger);
document.body.append(trigger);
Menu.create(trigger, {
closeOnSelect: false,
items: [
{ type: 'checkbox', id: 'wrap', label: 'Word wrap', checked: true },
{ type: 'group', label: 'Density', items: [
{ type: 'radio', id: 'compact', label: 'Compact', group: 'density', checked: false },
{ type: 'radio', id: 'comfortable', label: 'Comfortable', group: 'density', checked: true },
] },
],
});
trigger.addEventListener('aui-select', (event) => {
const { id, checked } = (event as CustomEvent<MenuSelectDetail>).detail;
document.body.dataset[id] = String(checked);
});
import { configureAbsoluteUI } from '@absoluteui/core';
configureAbsoluteUI({ components: { menu: { placement: 'bottom-end', typeahead: false } } });
import { Menu, type MenuSelectDetail } from '@absoluteui/components/menu';
import '@absoluteui/components/menu/style.css';
const trigger = document.querySelector<HTMLButtonElement>('#view-menu');
if (trigger) {
Menu.create(trigger, {
closeOnSelect: false,
items: [
{ type: 'checkbox', id: 'wrap', label: 'Word wrap', checked: true },
{ type: 'group', label: 'Density', items: [
{ type: 'radio', id: 'compact', label: 'Compact', group: 'density', checked: false },
{ type: 'radio', id: 'comfortable', label: 'Comfortable', group: 'density', checked: true },
] },
],
});
trigger.addEventListener('aui-select', (event) => {
const { id, checked } = (event as CustomEvent<MenuSelectDetail>).detail;
document.body.dataset[id] = String(checked);
});
}
import { Menu } from '@absoluteui/components/menu';
const trigger = document.querySelector<HTMLButtonElement>('#row-actions');
if (trigger) {
Menu.create(trigger, {
items: [
{ id: 'edit', label: 'Edit' },
{ id: 'share', label: 'Share', items: [{ id: 'email', label: 'Email' }, { id: 'link', label: 'Copy link' }] },
{ type: 'separator' },
{ id: 'delete', label: 'Delete', tone: 'danger' },
],
});
}
<button aria-haspopup="menu" aria-expanded aria-controls=menu>Actions</button> (consumer trigger)
<div class="aui-menu" role="menu" popover="manual" aria-labelledby=trigger> (inserted after the trigger)
<div class="aui-menu__item" role="menuitem" tabindex="-1" aria-labelledby aria-describedby> (tabindex="0" while focused)
<span class="aui-menu__check"> (reserved when the level has checkable items)
<span class="aui-menu__icon"> (optional)
<span class="aui-menu__text">
<span class="aui-menu__label">
<span class="aui-menu__description"> (optional)
<span class="aui-menu__shortcut"> (optional)
<svg class="aui-menu__submenu-icon"> (submenu parents)
<div class="aui-menu__separator" role="separator">
<div class="aui-menu__group" role="group" aria-labelledby=label>
<div class="aui-menu__group-label">
<div class="aui-menu__item" role="menuitemcheckbox | menuitemradio" aria-checked>
</div>
<div class="aui-menu aui-menu--submenu" role="menu" popover="manual" aria-labelledby=parent-label> (one per submenu)
Menu.create(trigger: HTMLButtonElement, options: MenuOptions): Menu

Global configuration key: menu (see Configuration).

Option Type Default Description
items readonly MenuItem[] Items: action (with optional submenu items), checkbox, radio, separator, and group. Ids are unique across the whole tree. Required.
label string Menu aria-label. Default: the menu is labelled by the trigger (aria-labelledby).
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.
placement Placement "bottom-start" Top-level popup placement relative to the trigger. Submenus always use right-start (left-start in RTL). Globally configurable.
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.
direction 'ltr' | 'rtl' "ltr" Submenu side, submenu arrow keys, and chevron mirroring. Globally configurable. Inherited from common configuration.
Property Type Description
trigger HTMLButtonElement The menu button.
element HTMLDivElement Top-level role=menu popup (popover=manual), inserted after the trigger.
isOpen boolean Whether the top-level menu is open.
Method Description
open(focus?: 'first' | 'last'): void Open and focus the first (default) or last enabled item.
close(): void Close all levels; focus returns to the trigger when it was inside the menu. No aui-select.
update(options: Partial<MenuOptions>): void Merge and apply options; new items reset checkbox/radio state and close open submenus.
destroy(): void Close, remove all popups, trigger attributes, listeners, and subscriptions.
Event Detail Bubbles Cancelable Description
aui-select { id: string; item: MenuSelectableItem; checked?: boolean } Yes No Dispatched on the trigger when the user activates an enabled item. 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 Menu popup (top level and submenus).
aui-menu--submenu Submenu popup.
aui-menu--has-check Level contains checkbox/radio items; reserves the check column.
aui-menu__item Menu item (menuitem, menuitemcheckbox, menuitemradio).
aui-menu__item--danger Destructive action tone.
aui-menu__check | __icon | __text | __label | __description | __shortcut | __submenu-icon Item parts.
aui-menu__separator role=separator.
aui-menu__group | __group-label role=group and its visible label.
Slot Default icon
submenu chevron-right
checked check
radio check

Tokens this stylesheet reads (verified in CI). Override them in a theme or with the Theme Designer.

--aui-border-width | --aui-color-border | --aui-color-error-fg | --aui-color-focus-ring | --aui-color-hover | --aui-color-surface | --aui-color-text | --aui-color-text-disabled | --aui-color-text-muted | --aui-control-height-sm | --aui-duration-fast | --aui-easing-standard | --aui-focus-ring-offset | --aui-focus-ring-width | --aui-font-family-sans | --aui-font-size-md | --aui-font-size-sm | --aui-font-weight-medium | --aui-icon-size-sm | --aui-line-height-tight | --aui-popover-max-height | --aui-radius-md | --aui-radius-sm | --aui-shadow-md | --aui-space-1 | --aui-space-2 | --aui-space-4

type MenuItem =
| { type?: 'item'; id: string; label: string; icon?: IconRef; description?: string; shortcut?: string; disabled?: boolean; tone?: 'default' | 'danger'; items?: readonly MenuItem[] }
| { type: 'checkbox'; id: string; label: string; checked: boolean; disabled?: boolean }
| { type: 'radio'; id: string; label: string; group: string; checked: boolean; disabled?: boolean }
| { type: 'separator' }
| { type: 'group'; label: string; items: readonly MenuItem[] };
interface MenuSelectDetail { id: string; item: MenuSelectableItem; checked?: boolean }
interface MenuIcons { submenu: IconRef; checked: IconRef; radio: IconRef }

Pattern: WAI-ARIA APG Menu Button and Menu (actions menu) with submenus; roving focus with real DOM focus on items.

Key Context Action
Enter / Space / ArrowDown Trigger focused Opens the menu and focuses the first enabled item.
ArrowUp Trigger focused Opens the menu and focuses the last 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 (emits aui-select and closes), 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 returns focus to the trigger; an enclosing Dialog stays open.
Tab / Shift+Tab Menu Closes all levels; focus moves to the next/previous element from the trigger.
Element Role
trigger button (native) with aria-haspopup=menu
popup and submenus menu
items menuitem, menuitemcheckbox, or menuitemradio
group group
separator separator
icons none (aria-hidden=true)
Attribute Element Value
aria-haspopup trigger and submenu parent items “menu”
aria-expanded trigger and submenu parent items “true” while the corresponding menu is open
aria-controls trigger and submenu parent items id of the controlled menu
aria-labelledby / aria-label top-level menu Trigger id by default; the label option sets aria-label instead
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-labelledby group Visible group label id
aria-disabled disabled items “true”; the item stays focusable but is not activatable
aria-checked checkbox and radio items “true” | “false”

Real DOM focus moves to items (tabindex -1) and follows the pointer on hover. Focused items show a background; keyboard focus adds a color-focus-ring outline (:focus-visible). Activation, Escape at the top level, and close() with focus inside return focus to the trigger.

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.

  • Disabled items stay focusable, per the APG, so screen-reader users discover them.
  • The shortcut hint is exposed as a description, not as part of the item name.
  • Each level is a separate overlay-stack entry, so Escape and outside presses close the topmost level first.
  • Manual screen-reader verification (NVDA, JAWS, VoiceOver) is pending.

closed, open, submenu open (parent item keeps the hover background), item focused (:focus-visible adds the ring), item disabled, item checked, danger item.

From Trigger To
closed trigger click, Enter, Space, ArrowDown / ArrowUp, open() open; first / last enabled item focused; aui-open
open ArrowRight (RTL: ArrowLeft), Enter, or click on a submenu parent; hover for 150 ms submenu open
submenu open ArrowLeft (RTL: ArrowRight), Escape submenu closed; focus on its parent item
open activate an action item closed; focus on the trigger; 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 on the trigger
open Tab, trigger click, outside press, focus moving elsewhere, close() closed
  • Real DOM focus moves to items (roving focus); screen readers announce each item as it receives focus.
  • Disabled items stay focusable (APG) so users can discover them; they cannot be activated.
  • Focus follows the pointer on hover, so pointer and keyboard users share one current item.
  • Focus returns to the trigger on activation and on Escape at the top level.
  • Manual screen-reader verification is pending (see Known limitations).

The popup is 10rem to min(22rem, 100vw - 1rem) wide. Its height is capped by popover-max-height and by the space available in the viewport, then it scrolls. The positioning engine flips and shifts every level to keep it on screen.

With direction: 'rtl' (inherited from the global direction by default), submenus open to the left (left-start), ArrowLeft opens a submenu and ArrowRight closes it, and the submenu chevron is mirrored through [data-direction='rtl']. Item content uses logical properties; the top-level start alignment follows the direction.

Colors come from role tokens only. Verified in the light, dark, and high-contrast themes. Danger items use color-error-fg text. In forced-colors mode the focused item is drawn in Highlight / HighlightText and disabled items in GrayText.

configureAbsoluteUI({ components: { menu: { placement: 'bottom-end', closeOnSelect: false } } });

Changes apply to existing instances: direction updates immediately; placement is re-applied to an open menu.

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; verified under a strict CSP with Trusted Types enforced.

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. Four delegated listeners per level, two on the trigger, and one configuration subscription. Budget: under 100 ms for Menu.create plus open() with 200 items.

Tested with 200 items in one level and three nested levels. For longer lists, prefer a Combobox with filtering.

  • An empty items array is allowed; the menu opens with focus on the menu container.
  • Duplicate ids anywhere in the tree, including submenus and groups, throw MENU_OPTIONS.
  • Activating a checked radio item keeps it checked and still emits aui-select with checked: true.
  • update({ items }) while open keeps focus on the item with the same id when it is still at the top level; otherwise focus moves to the first enabled item.
  • A trigger without an id gets a generated one, which destroy() removes.
Code Category Cause
MENU_TRIGGER configuration The trigger is not a <button> or has no parent node.
MENU_OPTIONS configuration items not an array, missing or empty label or id, duplicate id, empty submenu, missing radio group, non-boolean checked, or an empty label option.
CFG_INVALID_VALUE configuration Invalid placement, closeOnSelect, typeahead, or direction.

A failed construction leaves no rendering, trigger attributes, listeners, or subscriptions behind.

  • Submenus open after a 150 ms hover delay without a pointer-safe triangle.
  • Shortcut text is a hint; the menu does not bind shortcuts or set aria-keyshortcuts.
  • Radio groups are scoped by group name across the whole tree.
  • No horizontal menubar mode; use Menu Bar.
  • Screen-reader behavior has not yet been manually verified with NVDA, JAWS, and VoiceOver.
  • HTML or custom nodes inside items.
  • Link items (href); navigate in the aui-select listener.
  • Context menus positioned at the pointer.