<ui-menu>| Element | Attributes | Properties | Methods | Events |
|---|---|---|---|---|
<ui-menu> |
placement, trigger, for, aria-label |
.placement, .open |
open_at(x, y), open_at_element(el), set_items(...rows), refresh() |
select, beforeopen, open |
<ui-menu-item> |
value, disabled, destructive, checkable, aria-checked |
.value, .disabled, .destructive, .checked |
— | — |
Always supply a <button> child with a meaningful label. If no button is provided,
the component auto-creates one with textContent="..." and a console warning — suppress the
warning by adding aria-label="…" on <ui-menu>, which is forwarded to
the auto-created button — and kept in step with it, so a later write to the host reaches the
button too. A <button> you supply owns its own name and is never
overwritten.
A row that toggles something rather than firing a one-shot action carries
checkable: it is announced as a menuitemcheckbox, and its
aria-checked is seeded to false when the markup doesn't set one, so
a screen reader always reads a state. Read and write that state through the item's
.checked property.
Simple 3-item menu with a button trigger.
Menu items with leading icons.
Disabled items remain reachable with arrow keys, Home and End so keyboard users can discover them. Opening focuses the first row even when it is disabled. aria-disabled="true" announces its state, and Enter, Space or a click produces no select event.
Item with destructive attribute — red styling for danger actions.
Parent items open child menus on hover or ArrowRight. ArrowLeft returns to parent.
Hovering a parent row highlights it immediately but waits ~300 ms before opening its flyout, so sweeping down the list doesn't flash every submenu open. Once a flyout is open, moving the pointer diagonally toward it is tolerated even though the path crosses sibling rows — a safe triangle spanned by the pointer and the flyout's near edge suppresses the switch. Clicking a row or pressing ArrowRight opens immediately, with no delay.
Placement is collision-aware. A submenu prefers to open to the right of its row, but flips to
the left when it would leave the viewport — so the pattern works in a right-aligned overflow
menu — and a root menu flips above its trigger at the bottom edge, to the trigger's other side
at a side edge, or both at once in a corner. That sideways flip is not only for menus already
at a screen edge: a bottom-start menu in the right half of a narrow viewport runs
off the right just as a bottom-end menu in the left half runs off the left, which
is why a row of menu buttons spanning the width has no single placement that fits
them all. The disclosure chevron stays at the row's trailing edge either way: it marks
hierarchy, not the direction the flyout opens.
CSS handles placement without JavaScript rectangle measurements. Each picker uses anchor() insets and fallback positions for overflow. Root menus try flip-block, flip-inline and both together. Submenus try right, left, right-and-up, then left-and-up, using the default rule plus three named @position-try blocks. The browser recalculates placement without resize or scroll handlers.
Keep both offsets in the fallback blocks. The submenu's top or bottom offset subtracts var(--size-1), its vertical padding, to align its first row with the parent row. Each block also uses a negative margin on the opening side to maintain about 4 px of overlap. This prevents a gap that would dismiss the submenu as the pointer moves into it.
Code that depends on the flyout's position must measure the picker and its parent row. In particular, the pointer's safe triangle uses the actual near edge; the chosen CSS fallback may place the flyout on either side.
The same submenu rule applies at every depth, with each picker choosing a fallback independently. A third-level menu may therefore open rightward over the root menu even when its parent opened leftward. This temporary overlap is accepted: each level remains within the viewport and closes when the pointer leaves. Inheriting the parent's opening direction would require JavaScript to measure that placement and rewrite the child's fallback list, replacing the component's CSS-only positioning.
position-area with an inset in the same axis
Use either position-area or anchor() insets for a given axis. Both support fallbacks, but combining them on the same axis can disable position-try-fallbacks. In Chromium 145, a 166 px picker anchored at 1120..1280 in a 1280 px viewport correctly flipped to 951..1117. Adding right: anchor(right) beside position-area instead left it at 1114..1280, overlapping its parent row. An inset on the other axis did not cause this problem.
This failure leaves a submenu covering the parent rows despite a declared fallback list. To avoid it, <ui-menu> uses anchor() insets and explicit @position-try blocks throughout. A picker using only position-area can still flip correctly, as demonstrated by lib/tooltip.mjs.
Check the complete cascade for conflicting placement rules. A submenu also matches the root-menu rule because its :not() inside :where() constrains only the ancestor. Keep .ui-menu-picker outside :where() so the submenu rule has enough specificity to override the base inset: auto. Wrapping the whole selector would remove that specificity.
Test alignment as well as overflow. A correct flip moves the picker to the trigger's opposite edge. Browser safe alignment may instead slide it against the viewport edge and over its trigger. Both pass a no-overflow assertion. Check the aligned edge: -end prefers the trigger's right edge, -start its left, and a flip swaps them.
Give geometry tests fixed picker and viewport widths that force overflow. Text-dependent widths vary between engines and may fit unexpectedly. A viewport that is too wide will pass even with fallbacks removed. These fixture dimensions test flipping; they do not limit the application's supported widths.
Dynamic item add/remove via JavaScript.
Several menus on page — only one open at a time (popover auto-dismiss).
Control dropdown direction with the placement attribute.
Set trigger="contextmenu" to attach the menu to another element —
named via for, or the menu's own parent when for is
omitted. Give <ui-menu> an aria-label: in this mode
there is no button whose text could name the menu, so the label is the menu's
only accessible name (a console warning says so if it is missing).
Submenus work the same as in button-trigger mode.
The component observes aria-label on <ui-menu> and updates the menu's accessible name whenever it changes. This includes apply_static_i18n() applying data-i18n-attr="aria-label:…" after the catalog loads. Without that observer, a translated page could retain an English menu name for screen readers.
The target element is the trigger — it carries
aria-haspopup="menu" and aria-expanded, and it opens the
menu two ways:
anchor() insets and @position-try flipping as a
button-triggered menu, so it stays on screen at a viewport edge. Use the
placement attribute to change which corner it opens from.
Cancelling the keydown is what stops the browser from also firing its own
contextmenu for the same key press — Chromium emits one (with
button: -1) at coordinates of its choosing, which would re-open the
menu at a point and lose the element anchor; Firefox emits none.
The context-menu target must be focusable for Shift+F10 and focus restoration to work. The component adds tabindex="0" unless the target is natively focusable or already has tabindex. A widget that manages focus itself can supply tabindex="-1".
If the menu has no focusable items, focus the picker. For example, set_items() may supply only role="none" notes explaining why no actions are available. Keeping focus on the trigger would leave those notes unannounced. The picker has tabindex="-1" and the menu's accessible name; Escape restores focus as usual. It uses its border and shadow to identify the container, without a focus ring around the whole panel, including hover-opened submenus.
The current row uses a --surface-3 background on hover or focus. Keyboard focus also adds an inset --brand outline so users can identify the focused row with sufficient contrast.
The focus ring supplies the required contrast. WCAG 2.2 SC 1.4.11 requires an author-supplied indicator to contrast 3:1 with adjacent colors; SC 2.4.13 requires the same contrast for the changed area. The fill measures only 1.12:1 in the light theme and 1.35:1 in dark, and disappears under forced-colors: active. The ring measures 4.00–6.72:1 across row surfaces in both themes and switches to a system color under forced colors.
When theming, adjust --surface-3 for row highlighting and --brand for the focus ring separately. Preserve the ring's contrast requirement.
Disabled rows remain in the roving tabindex and can receive :focus-visible through arrow navigation. Their reduced opacity lowers the ring's contrast to about 2.05:1. SC 1.4.11 exempts inactive components from the usual contrast requirement.
Closing restores focus to the element the menu was opened from — one
rule, whatever the opener. An anchored open restores to its anchor: the
focused element for Shift+F10 (under the roving-tabindex opt-out
above, that is the descendant the widget had focused), or the element handed to
open_at_element(). A coordinate open (a right-click,
open_at(x, y)) has no anchor, so it restores to the target itself —
never to the right-clicked descendant, which is rarely focusable.
Escape and row activation restore focus directly. Clicking outside can instead hide the focused row and leave focus on <body>. The component detects that case through focusout with a null relatedTarget and retries restoration until it succeeds. If the user clicked another control, leave focus on that control.
The picker's keydown listener cancels Escape and closes the menu without calling stopPropagation(). Ancestor listeners therefore receive the event after the menu is closed. Checking only document.querySelector(':popover-open, dialog[open]') would miss the closed menu and could trigger a second action. Check e.defaultPrevented as well.
Check e.defaultPrevented to see whether an inner component has already handled Escape. The menu makes the same check before acting, which lets a submenu close without also closing its parent menu.
Check both defaultPrevented and whether an overlay remains open. An overlay inside the handler's subtree may already have closed during dispatch, leaving only the cancelled event as evidence. An overlay outside the subtree may still be open and awaiting browser light-dismiss, with an uncancelled event. A handler that should act only when no overlay needs Escape must cover both cases, then cancel any event it handles so outer handlers can do the same:
el.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return
// an overlay inside this subtree took it, and has already closed
if (e.defaultPrevented) return
// an overlay outside it took it, and has not closed yet
if (document.querySelector(':popover-open, dialog[open]')) return
e.preventDefault()
close_whatever_this_handler_owns()
})
Test these cases with real key presses in a browser. A unit-test stand-in using <dialog open> stays open throughout dispatch, so it cannot demonstrate the difference between the two checks.
Other overlays have distinct Escape behavior. The notification panel behaves like a menu. A combobox and a select cancel without stopping propagation when they act, and leave the event alone when there is nothing to do. A popover also stops events it handles, so bubbling ancestor listeners do not receive them. Capture listeners run before cancellation and can still observe those events.
anchor-name, one element
An anchored open temporarily adds an inline anchor-name to the focused element for Shift+F10, or to the element passed to open_at_element(). The menu adds its name to the element's list and removes only that name on close, so a tooltip or another surface anchored to the same element keeps its own name (Sharing an anchor). Each menu has a unique name, held by only one element at a time. If two elements share it, CSS resolves the anchor to the later one in tree order. Coordinate opens use the event position without an anchor; the next anchored open clears those coordinates.
Release the old anchor name at the start of every open operation, even if the picker is already hidden. Popover toggle events are queued. One gesture can dismiss and reopen a menu before its close handler runs; the handler then correctly sees an open menu and does nothing. Without unconditional release, the previous target could retain the anchor name and make the menu appear not to move.
Perform immediate cleanup in the explicit close path. A queued toggle handler must first check that the menu is still closed, or it could write aria-expanded="false" after the menu has reopened.
Both openers, and both open_at… methods, dispatch
beforeopen and then open with the same detail:
source — 'pointer', 'keyboard', or
'api' for a direct method call.target — the element the open acted on: the right-clicked element,
the focused element, or the one passed to open_at_element();
null for a bare open_at(x, y).x / y — pointer coordinates, null on the
anchored path.
beforeopen is cancelable: call
preventDefault() to decline this particular open — a right-click on
something the page has no actions for. The menu stays shut, and the native context
menu is handed back rather than suppressed. This is the hook that lets an app keep
its own opener logic without hand-rolling the opener itself.
open_at_element(el) is the public anchored open, for a page opener that
has an element rather than a pointer position — a keyboard shortcut, a marker the
user arrowed to. refresh() re-reads the items after building them
imperatively, for when they must be live in the same turn (the MutationObserver
picks them up on its own, but only on a later microtask).
Declare fixed menu rows as children and update their state in beforeopen, including disabled, aria-checked and submenu contents. This preserves the menu and its anchor instead of rebuilding them on every open.
Set a row's .disabled property so aria-disabled updates too. The bare attribute alone can leave a visibly disabled row announced as available. Put the reason in title and clear it when enabling the row again. Disabled rows remain keyboard-reachable (§3), allowing users to read that explanation.
For rows that vary with the target, such as recordings or annotations, call set_items(...rows). It replaces the contents and updates keyboard navigation immediately. Appending to the host relies on a later MutationObserver microtask, which is too late inside beforeopen. The method accepts any node, including <hr role="separator"> and information rows, but only <ui-menu-item> rows become navigable items.
menu.addEventListener('beforeopen', (e) => {
const row = e.detail.target?.closest('[data-id]')
if (!row) return e.preventDefault() // nothing here — give the native menu back
menu.set_items(...rows_for(row.dataset.id))
})