Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Menu

A popover menu of actions, opened by click or hover.

Installation

pnpm dlx zazz-ui add menu
npx zazz-ui add menu
yarn dlx zazz-ui add menu
bunx zazz-ui add menu

Also 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

menu attributes
AttributeKindValuesNotes
data-ui="menu"Identityfree valueIdentity token in data-ui.
<ui-menu>Tag formfree valueTag form; styled and, when scripted, registered under this name.
data-menu-slotSlotpopoverSlot: a part inside the primitive.

Requires: popover, button.

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.

menu hooks
HookDefaultState
--ui-menu-button-roundedvar(--radius-sm)—
--ui-menu-gapvar(--ui-field-option-gap)—
--ui-menu-min-wcalc(var(--spacing) * 52)—
--ui-menu-shadowvar(--shadow-sm)—