Menu
Popover menus using the Popover API, anchor positioning, and native light dismiss, with optional hover open and arrow-key navigation.
A <ui-menu> wraps a trigger button (popovertarget) and its [data-slot="menu-popover"] panel (popover="auto"). The class form <div class="ui-menu"> is equivalent and needs no JavaScript. The root names the trigger plus panel assembly, while the native <menu> element remains the list inside the panel. Menu items use ghost buttons. Position the panel with data-side and data-align.
When the kit script is loaded, the tag form adds arrow-key navigation: ArrowDown/ArrowUp on the closed trigger opens the panel and focuses the first or last item. Inside the panel, arrow keys navigate between items (wrapping and skipping disabled items), and Home/End jump to the ends. Escape and light dismiss remain standard Popover API behavior.
Default
<div class="flex gap-sm"> <ui-menu> <button class="ui-button" type="button" popovertarget="menu-example-1">Bottom / start</button> <div id="menu-example-1" data-slot="menu-popover" data-side="bottom" data-align="start" popover="auto" > <menu> <li class="font-strong text-eyebrow text-muted-foreground p-xs">Group 1</li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 1</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 2</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 3</a></li> <hr class="ui-separator my-xs" /> <li class="font-strong text-eyebrow text-muted-foreground p-xs">Group 2</li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 4</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 5</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 6</a></li> </menu> </div> </ui-menu> <ui-menu> <button class="ui-button" type="button" popovertarget="menu-example-2">Bottom / center</button> <div id="menu-example-2" data-slot="menu-popover" data-side="bottom" data-align="center" popover="auto" > <menu> <li class="font-strong text-eyebrow text-muted-foreground p-xs">Group 1</li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 1</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 2</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 3</a></li> <hr class="ui-separator my-xs" /> <li class="font-strong text-eyebrow text-muted-foreground p-xs">Group 2</li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 4</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 5</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 6</a></li> </menu> </div> </ui-menu> <ui-menu> <button class="ui-button" type="button" popovertarget="menu-example-3">Bottom / end</button> <div id="menu-example-3" data-slot="menu-popover" data-side="bottom" data-align="end" popover="auto" > <menu> <li class="font-strong text-eyebrow text-muted-foreground p-xs">Group 1</li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 1</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 2</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 3</a></li> <hr class="ui-separator my-xs" /> <li class="font-strong text-eyebrow text-muted-foreground p-xs">Group 2</li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 4</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 5</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Link 6</a></li> </menu> </div> </ui-menu></div>// primitives/menu/menu.js"use strict";/** * @fileoverview `<ui-menu>`: HTML web component for keyboard-enhanced menus. * @description Light-DOM custom element that augments the CSS-only menu * pattern (trigger + anchored `[data-slot~="menu-popover"]` panel) with * arrow-key navigation. The class form `.ui-menu` stays fully functional * without JavaScript: the Popover API provides open/close, light dismiss, * and focus return on its own. * * Keyboard behavior: * - ArrowDown / ArrowUp on the closed trigger open the panel and focus the * first / last item. * - ArrowDown / ArrowUp inside the open panel move focus between items, * wrapping around and skipping disabled items. * - Home / End jump to the first / last item. * - Escape and light dismiss are native Popover API behavior (no code here). * * The menu keeps the honest disclosure posture: items are plain links and * buttons, and no `role="menu"` is claimed. Add the full ARIA menu contract * yourself only if every item is an action and you implement the rest of the * pattern (typeahead, close-on-activate). * * @example * <ui-menu> * <button class="ui-button" type="button" popovertarget="m1">Open</button> * <div id="m1" data-slot="menu-popover" popover="auto"> * <menu> * <li><a href="/docs" class="ui-button justify-start" data-variant="ghost">Docs</a></li> * </menu> * </div> * </ui-menu> */import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";class UiMenu extends ZazzElement { setup(signal) { this.addEventListener("keydown", (event) => this.#onKeydown(event), { signal }); } /** * @description The menu's own panel: a direct child so nested menus keep * their panels to themselves. * * @returns The panel element, or null when the markup is incomplete. */ #panel() { const panel = this.querySelector(':scope > [data-slot~="menu-popover"]'); return panel instanceof HTMLElement ? panel : null; } /** * @description Focusable items inside the panel, in DOM order. * * @param panel - The menu panel. * @returns Enabled links and buttons the arrow keys move between. */ #items(panel) { return Array.from(panel.querySelectorAll("a[href], button")) .filter((node) => node instanceof HTMLElement) .filter((item) => !item.hasAttribute("disabled") && item.getAttribute("aria-disabled") !== "true" && item.closest("ui-menu, .ui-menu") === this); } /** * @description Routes arrow-key, Home, and End presses: opens the panel from * the trigger, or moves focus between items inside the open panel. * * @param event - The keydown event. */ #onKeydown(event) { if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; const target = event.target; if (!(target instanceof HTMLElement)) return; // Ignore keys that belong to a nested ui-menu if (target.closest("ui-menu") !== this) return; const panel = this.#panel(); if (!panel) return; const isTrigger = target.parentElement === this && (target.hasAttribute("popovertarget") || target.hasAttribute("interestfor")); const open = panel.matches(":popover-open, .\\:popover-open"); if (isTrigger && !open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { event.preventDefault(); panel.showPopover(); const items = this.#items(panel); items[event.key === "ArrowDown" ? 0 : items.length - 1]?.focus(); return; } if (!open || !panel.contains(target)) return; const items = this.#items(panel); if (items.length === 0) return; const index = items.indexOf(target); let nextIndex; switch (event.key) { case "ArrowDown": nextIndex = index === -1 ? 0 : (index + 1) % items.length; break; case "ArrowUp": nextIndex = index === -1 ? items.length - 1 : (index - 1 + items.length) % items.length; break; case "Home": nextIndex = 0; break; case "End": nextIndex = items.length - 1; break; default: return; } event.preventDefault(); items[nextIndex].focus(); }}defineZazzElement("ui-menu", UiMenu);export { UiMenu };/** * menu.css: Menu (ui-menu | .ui-menu, [data-slot~="menu-popover"]) * * @layer variables, components * @requires layers.css, _variables.css, popover.css, button.css * @uses popovertarget + [popover]: inherits Popover API from popover.css * @uses interestfor: optional hover/focus open on the same trigger * (Interest Invokers, Chromium 142+; polyfill via invokers) * @uses anchor-name, anchor-scope, position-anchor: tether panel to trigger * @uses data-side / data-align: maps to --ui-popover-position-* in popover.css * @tokens --ui-menu-* (@layer variables) * @consumedby select.css (--ui-menu-button-radius) * @see menu.ts: optional arrow-key navigation; the class form stays no-JS */@layer variables { :root { --ui-menu-button-radius: var(--radius-sm); --ui-menu-min-inline-size: var(--step-52); --ui-menu-option-gap: var(--ui-field-option-gap); --ui-menu-shadow: var(--shadow-sm); }}@layer zazz.components { /* =========================================================================== MENU: trigger + anchored popover of actions/links - Inherits --ui-popover-* tokens and [popover] surface/positioning from popover.css. - Only menu-specific wiring lives here: anchor scope/name and nested buttons. - The native <menu> element stays inside the panel as the list; the root names the trigger + panel assembly. - Explicit anchor-name covers both popovertarget (click) and interestfor (hover/focus) triggers; implicit interest anchors are not polyfilled. =========================================================================== */ :where(ui-menu, .ui-menu) { anchor-scope: --ui-menu-trigger; display: grid; gap: var(--ui-menu-option-gap); } :where(ui-menu, .ui-menu) > :where([popovertarget], [interestfor]) { anchor-name: --ui-menu-trigger; } [data-slot~="menu-popover"] { position-anchor: --ui-menu-trigger; min-inline-size: var(--ui-menu-min-inline-size); box-shadow: var(--ui-menu-shadow); } [data-slot~="menu-popover"] .ui-button { --ui-button-radius: var(--ui-menu-button-radius); }}Hover open
Add interestfor alongside popovertarget (pointing to the same panel id) to open the menu on hover, focus, or long-press via the Interest Invokers API. Clicking still toggles, and popover="auto" maintains light dismiss.
<div class="flex gap-sm"> <ui-menu> <button class="ui-button" type="button" popovertarget="menu-interest-example-1" interestfor="menu-interest-example-1" > Hover or click </button> <div id="menu-interest-example-1" data-slot="menu-popover" data-side="bottom" data-align="start" popover="auto" > <menu> <li class="font-strong text-eyebrow text-muted-foreground p-xs">Account</li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Profile</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Settings</a></li> <hr class="ui-separator my-xs" /> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Sign out</a></li> </menu> </div> </ui-menu> <ui-menu> <button class="ui-button" type="button" data-variant="outline" popovertarget="menu-interest-example-2" interestfor="menu-interest-example-2" > Resources </button> <div id="menu-interest-example-2" data-slot="menu-popover" data-side="bottom" data-align="start" popover="auto" > <menu> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Documentation</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Changelog</a></li> <li><a href="#" class="ui-button justify-start" data-variant="ghost">Support</a></li> </menu> </div> </ui-menu></div>// primitives/menu/menu.js"use strict";/** * @fileoverview `<ui-menu>`: HTML web component for keyboard-enhanced menus. * @description Light-DOM custom element that augments the CSS-only menu * pattern (trigger + anchored `[data-slot~="menu-popover"]` panel) with * arrow-key navigation. The class form `.ui-menu` stays fully functional * without JavaScript: the Popover API provides open/close, light dismiss, * and focus return on its own. * * Keyboard behavior: * - ArrowDown / ArrowUp on the closed trigger open the panel and focus the * first / last item. * - ArrowDown / ArrowUp inside the open panel move focus between items, * wrapping around and skipping disabled items. * - Home / End jump to the first / last item. * - Escape and light dismiss are native Popover API behavior (no code here). * * The menu keeps the honest disclosure posture: items are plain links and * buttons, and no `role="menu"` is claimed. Add the full ARIA menu contract * yourself only if every item is an action and you implement the rest of the * pattern (typeahead, close-on-activate). * * @example * <ui-menu> * <button class="ui-button" type="button" popovertarget="m1">Open</button> * <div id="m1" data-slot="menu-popover" popover="auto"> * <menu> * <li><a href="/docs" class="ui-button justify-start" data-variant="ghost">Docs</a></li> * </menu> * </div> * </ui-menu> */import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";class UiMenu extends ZazzElement { setup(signal) { this.addEventListener("keydown", (event) => this.#onKeydown(event), { signal }); } /** * @description The menu's own panel: a direct child so nested menus keep * their panels to themselves. * * @returns The panel element, or null when the markup is incomplete. */ #panel() { const panel = this.querySelector(':scope > [data-slot~="menu-popover"]'); return panel instanceof HTMLElement ? panel : null; } /** * @description Focusable items inside the panel, in DOM order. * * @param panel - The menu panel. * @returns Enabled links and buttons the arrow keys move between. */ #items(panel) { return Array.from(panel.querySelectorAll("a[href], button")) .filter((node) => node instanceof HTMLElement) .filter((item) => !item.hasAttribute("disabled") && item.getAttribute("aria-disabled") !== "true" && item.closest("ui-menu, .ui-menu") === this); } /** * @description Routes arrow-key, Home, and End presses: opens the panel from * the trigger, or moves focus between items inside the open panel. * * @param event - The keydown event. */ #onKeydown(event) { if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; const target = event.target; if (!(target instanceof HTMLElement)) return; // Ignore keys that belong to a nested ui-menu if (target.closest("ui-menu") !== this) return; const panel = this.#panel(); if (!panel) return; const isTrigger = target.parentElement === this && (target.hasAttribute("popovertarget") || target.hasAttribute("interestfor")); const open = panel.matches(":popover-open, .\\:popover-open"); if (isTrigger && !open && (event.key === "ArrowDown" || event.key === "ArrowUp")) { event.preventDefault(); panel.showPopover(); const items = this.#items(panel); items[event.key === "ArrowDown" ? 0 : items.length - 1]?.focus(); return; } if (!open || !panel.contains(target)) return; const items = this.#items(panel); if (items.length === 0) return; const index = items.indexOf(target); let nextIndex; switch (event.key) { case "ArrowDown": nextIndex = index === -1 ? 0 : (index + 1) % items.length; break; case "ArrowUp": nextIndex = index === -1 ? items.length - 1 : (index - 1 + items.length) % items.length; break; case "Home": nextIndex = 0; break; case "End": nextIndex = items.length - 1; break; default: return; } event.preventDefault(); items[nextIndex].focus(); }}defineZazzElement("ui-menu", UiMenu);export { UiMenu };/** * menu.css: Menu (ui-menu | .ui-menu, [data-slot~="menu-popover"]) * * @layer variables, components * @requires layers.css, _variables.css, popover.css, button.css * @uses popovertarget + [popover]: inherits Popover API from popover.css * @uses interestfor: optional hover/focus open on the same trigger * (Interest Invokers, Chromium 142+; polyfill via invokers) * @uses anchor-name, anchor-scope, position-anchor: tether panel to trigger * @uses data-side / data-align: maps to --ui-popover-position-* in popover.css * @tokens --ui-menu-* (@layer variables) * @consumedby select.css (--ui-menu-button-radius) * @see menu.ts: optional arrow-key navigation; the class form stays no-JS */@layer variables { :root { --ui-menu-button-radius: var(--radius-sm); --ui-menu-min-inline-size: var(--step-52); --ui-menu-option-gap: var(--ui-field-option-gap); --ui-menu-shadow: var(--shadow-sm); }}@layer zazz.components { /* =========================================================================== MENU: trigger + anchored popover of actions/links - Inherits --ui-popover-* tokens and [popover] surface/positioning from popover.css. - Only menu-specific wiring lives here: anchor scope/name and nested buttons. - The native <menu> element stays inside the panel as the list; the root names the trigger + panel assembly. - Explicit anchor-name covers both popovertarget (click) and interestfor (hover/focus) triggers; implicit interest anchors are not polyfilled. =========================================================================== */ :where(ui-menu, .ui-menu) { anchor-scope: --ui-menu-trigger; display: grid; gap: var(--ui-menu-option-gap); } :where(ui-menu, .ui-menu) > :where([popovertarget], [interestfor]) { anchor-name: --ui-menu-trigger; } [data-slot~="menu-popover"] { position-anchor: --ui-menu-trigger; min-inline-size: var(--ui-menu-min-inline-size); box-shadow: var(--ui-menu-shadow); } [data-slot~="menu-popover"] .ui-button { --ui-button-radius: var(--ui-menu-button-radius); }}Accessibility
The menu is a disclosure pattern of links and buttons rather than an ARIA role="menu". The menu role carries a strict menu keyboard contract (menuitem roles, typeahead, close-on-activate) meant for desktop app toolbars rather than website navigation. popovertarget already communicates expanded and collapsed state to assistive technology. If every item in your panel represents an application action, you can add role="menu" and role="menuitem" attributes directly.
API
| Attribute | Target | Values |
|---|---|---|
popovertarget | Trigger | ID of [data-slot="menu-popover"] (click toggle) |
interestfor | Trigger | Same ID: adds hover/focus open (optional) |
data-side | [data-slot="menu-popover"] | top, bottom, left, right |
data-align | [data-slot="menu-popover"] | start, center, end |