API
Menu
A popover menu of actions, opened by click or hover.
Installation
pnpm dlx zazz-ui add menunpx zazz-ui add menuyarn dlx zazz-ui add menubunx zazz-ui add menuAlso adds fields, kbd, button, popover, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.
Examples
Default
<div
style="
--display: flex;
--gap: 4;
"
>
<ui-menu>
<button data-ui="button" type="button" popovertarget="menu-example-1">Bottom / start</button>
<div
id="menu-example-1"
data-menu-slot="popover"
data-popover-side="bottom"
data-popover-align="start"
popover="auto"
>
<menu>
<li
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: strong;
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Group 1
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 1</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 2</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 3</a
>
</li>
<hr data-ui="separator" style="--my: 2" />
<li
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: strong;
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Group 2
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 4</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 5</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 6</a
>
</li>
</menu>
</div>
</ui-menu>
<ui-menu>
<button data-ui="button" type="button" popovertarget="menu-example-2">Bottom / center</button>
<div
id="menu-example-2"
data-menu-slot="popover"
data-popover-side="bottom"
data-popover-align="center"
popover="auto"
>
<menu>
<li
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: strong;
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Group 1
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 1</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 2</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 3</a
>
</li>
<hr data-ui="separator" style="--my: 2" />
<li
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: strong;
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Group 2
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 4</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 5</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 6</a
>
</li>
</menu>
</div>
</ui-menu>
<ui-menu>
<button data-ui="button" type="button" popovertarget="menu-example-3">Bottom / end</button>
<div
id="menu-example-3"
data-menu-slot="popover"
data-popover-side="bottom"
data-popover-align="end"
popover="auto"
>
<menu>
<li
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: strong;
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Group 1
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 1</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 2</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 3</a
>
</li>
<hr data-ui="separator" style="--my: 2" />
<li
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: strong;
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Group 2
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 4</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 5</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Link 6</a
>
</li>
</menu>
</div>
</ui-menu>
</div>/**
* menu.css: Menu (ui-menu | [data-ui~="menu"]; slot data-menu-slot="popover")
*
* @layer variables, components
* @requires _layers.css, _variables.css, _properties.css (--_*-resolved), 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-popover-side / data-popover-align: maps to --ui-popover-position-* in popover.css
* @tokens --ui-menu-* (@layer variables)
* @consumedby select.css (--ui-menu-button-rounded), autocomplete.css, combobox.css, command.css (--ui-menu-shadow)
* @see menu.ts: optional arrow-key navigation; both forms stay no-JS
*/
@layer variables {
:root {
--ui-menu-button-rounded: var(--radius-sm);
/* a scale number or a length */
--ui-menu-min-w: calc(var(--spacing) * 52);
--ui-menu-gap: var(--ui-field-option-gap);
--ui-menu-shadow: var(--shadow-sm);
}
}
@layer ui.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, [data-ui~="menu"]) {
anchor-scope: --ui-menu-trigger;
display: grid;
--_gap-len: var(--_gap-resolved, var(--ui-menu-gap));
--_gap-num: var(--_gap-resolved, var(--ui-menu-gap));
gap: calc(var(--_gap-len) + var(--_gap-num) * var(--spacing));
}
:where(ui-menu, [data-ui~="menu"]) > :where([popovertarget], [interestfor]) {
anchor-name: --ui-menu-trigger;
}
:where(ui-menu, [data-ui~="menu"]) [data-menu-slot~="popover"] {
position-anchor: --ui-menu-trigger;
--_min-w-len: var(--_min-w-resolved, var(--ui-menu-min-w));
--_min-w-num: var(--_min-w-resolved, var(--ui-menu-min-w));
min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
box-shadow: var(--_shadow-resolved, var(--ui-menu-shadow));
}
:where(ui-menu, [data-ui~="menu"]) [data-menu-slot~="popover"] [data-ui~="button"] {
--ui-button-rounded: var(--ui-menu-button-rounded);
}
}// 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-menu-slot~="popover"]` panel) with
* arrow-key navigation. The attribute form `[data-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 data-ui="button" type="button" popovertarget="m1">Open</button>
* <div id="m1" data-menu-slot="popover" popover="auto">
* <menu>
* <li><a href="/docs" data-ui="button" data-button-variant="ghost" style="--justify: start">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-menu-slot~="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, [data-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, [data-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");
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 };Interest
<div
style="
--display: flex;
--gap: 4;
"
>
<ui-menu>
<button
data-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-menu-slot="popover"
data-popover-side="bottom"
data-popover-align="start"
popover="auto"
>
<menu>
<li
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: strong;
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Account
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Profile</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Settings</a
>
</li>
<hr data-ui="separator" style="--my: 2" />
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Sign out</a
>
</li>
</menu>
</div>
</ui-menu>
<ui-menu>
<button
data-ui="button"
type="button"
data-button-variant="outline"
popovertarget="menu-interest-example-2"
interestfor="menu-interest-example-2"
>
Resources
</button>
<div
id="menu-interest-example-2"
data-menu-slot="popover"
data-popover-side="bottom"
data-popover-align="start"
popover="auto"
>
<menu>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Documentation</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Changelog</a
>
</li>
<li>
<a href="#" data-ui="button" data-button-variant="ghost" style="--justify: start"
>Support</a
>
</li>
</menu>
</div>
</ui-menu>
</div>/**
* menu.css: Menu (ui-menu | [data-ui~="menu"]; slot data-menu-slot="popover")
*
* @layer variables, components
* @requires _layers.css, _variables.css, _properties.css (--_*-resolved), 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-popover-side / data-popover-align: maps to --ui-popover-position-* in popover.css
* @tokens --ui-menu-* (@layer variables)
* @consumedby select.css (--ui-menu-button-rounded), autocomplete.css, combobox.css, command.css (--ui-menu-shadow)
* @see menu.ts: optional arrow-key navigation; both forms stay no-JS
*/
@layer variables {
:root {
--ui-menu-button-rounded: var(--radius-sm);
/* a scale number or a length */
--ui-menu-min-w: calc(var(--spacing) * 52);
--ui-menu-gap: var(--ui-field-option-gap);
--ui-menu-shadow: var(--shadow-sm);
}
}
@layer ui.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, [data-ui~="menu"]) {
anchor-scope: --ui-menu-trigger;
display: grid;
--_gap-len: var(--_gap-resolved, var(--ui-menu-gap));
--_gap-num: var(--_gap-resolved, var(--ui-menu-gap));
gap: calc(var(--_gap-len) + var(--_gap-num) * var(--spacing));
}
:where(ui-menu, [data-ui~="menu"]) > :where([popovertarget], [interestfor]) {
anchor-name: --ui-menu-trigger;
}
:where(ui-menu, [data-ui~="menu"]) [data-menu-slot~="popover"] {
position-anchor: --ui-menu-trigger;
--_min-w-len: var(--_min-w-resolved, var(--ui-menu-min-w));
--_min-w-num: var(--_min-w-resolved, var(--ui-menu-min-w));
min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
box-shadow: var(--_shadow-resolved, var(--ui-menu-shadow));
}
:where(ui-menu, [data-ui~="menu"]) [data-menu-slot~="popover"] [data-ui~="button"] {
--ui-button-rounded: var(--ui-menu-button-rounded);
}
}// 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-menu-slot~="popover"]` panel) with
* arrow-key navigation. The attribute form `[data-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 data-ui="button" type="button" popovertarget="m1">Open</button>
* <div id="m1" data-menu-slot="popover" popover="auto">
* <menu>
* <li><a href="/docs" data-ui="button" data-button-variant="ghost" style="--justify: start">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-menu-slot~="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, [data-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, [data-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");
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 };Behavior
primitives/menu/menu.js
<ui-menu>: HTML web component for keyboard-enhanced menus. Light-DOM custom element that augments the CSS-only menu pattern (trigger + anchored [data-menu-slot~="popover"] panel) with arrow-key navigation. The attribute form [data-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).
Attributes
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-ui="menu" | Identity | free value | Identity token in data-ui. |
<ui-menu> | Tag form | free value | Tag form; styled and, when scripted, registered under this name. |
data-menu-slot | Slot | popover | Slot: a part inside the primitive. |
Hooks
Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.
| Hook | Default | State |
|---|---|---|
--ui-menu-button-rounded | var(--radius-sm) | — |
--ui-menu-gap | var(--ui-field-option-gap) | — |
--ui-menu-min-w | calc(var(--spacing) * 52) | — |
--ui-menu-shadow | var(--shadow-sm) | — |