Menu
import { Menu } from '@absoluteui/components/menu';import '@absoluteui/components/menu/style.css';Using Angular? See Menu for Angular.
What it does
Section titled “What it does”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.
When to use
Section titled “When to use”- 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.
When not to use
Section titled “When not to use”- 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.
Examples
Section titled “Examples”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);});Global defaults
Section titled “Global defaults”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); });}More usage
Section titled “More usage”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' }, ], });}Anatomy
Section titled “Anatomy”<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)API reference
Section titled “API reference”Factory
Section titled “Factory”Menu.create(trigger: HTMLButtonElement, options: MenuOptions): MenuGlobal configuration key: menu (see Configuration).
Options
Section titled “Options”| 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. |
Properties
Section titled “Properties”| 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. |
Methods
Section titled “Methods”| 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. |
Events
Section titled “Events”| 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. |
CSS classes
Section titled “CSS classes”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. |
Icon slots
Section titled “Icon slots”| Slot | Default icon |
|---|---|
submenu |
chevron-right |
checked |
check |
radio |
check |
Design tokens
Section titled “Design tokens”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
TypeScript definitions
Section titled “TypeScript definitions”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 }Accessibility
Section titled “Accessibility”Pattern: WAI-ARIA APG Menu Button and Menu (actions menu) with submenus; roving focus with real DOM focus on items.
Keyboard
Section titled “Keyboard”| 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) |
ARIA attributes
Section titled “ARIA attributes”| 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.
WCAG 2.2 success criteria addressed
Section titled “WCAG 2.2 success criteria addressed”| 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.
Notes and known caveats
Section titled “Notes and known caveats”- 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.
Behavior
Section titled “Behavior”States
Section titled “States”closed, open, submenu open (parent item keeps the hover background), item focused (:focus-visible adds the ring), item disabled, item checked, danger item.
State transitions
Section titled “State transitions”| 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 |
Accessibility notes
Section titled “Accessibility notes”- 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).
Responsive behavior
Section titled “Responsive behavior”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.
Right-to-left
Section titled “Right-to-left”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.
Theming
Section titled “Theming”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.
Global configuration
Section titled “Global configuration”configureAbsoluteUI({ components: { menu: { placement: 'bottom-end', closeOnSelect: false } } });Changes apply to existing instances: direction updates immediately; placement is re-applied to an open menu.
Component configuration
Section titled “Component configuration”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.
Security
Section titled “Security”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.
Data handling
Section titled “Data handling”No persistence, network access, or telemetry. Checkbox and radio state is kept in memory and reset by update({ items }).
Performance
Section titled “Performance”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.
Supported data scale
Section titled “Supported data scale”Tested with 200 items in one level and three nested levels. For longer lists, prefer a Combobox with filtering.
Edge cases
Section titled “Edge cases”- An empty
itemsarray 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-selectwithchecked: 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.
Errors
Section titled “Errors”| 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.
Known limitations
Section titled “Known limitations”- 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.
Unsupported scenarios
Section titled “Unsupported scenarios”- HTML or custom nodes inside items.
- Link items (
href); navigate in theaui-selectlistener. - Context menus positioned at the pointer.