Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Menubar

A row of menus, composed from menu and button.

Installation

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

Also adds separator, fields, kbd, button, popover, menu, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.

Examples

Default

<div
  aria-label="Application menu"
  style="
    --display: flex;
    --items: center;
    --p: 2;
    --gap: 1px;
    --w: 100%;
    --bg: var(--color-card);
    --border-b: 1;
  "
>
  <ui-menu>
    <button
      data-ui="button"
      data-button-variant="ghost"
      type="button"
      popovertarget="menubar-example-file"
      interestfor="menubar-example-file"
    >
      File
    </button>
    <div
      id="menubar-example-file"
      data-menu-slot="popover"
      data-popover-side="bottom"
      data-popover-align="start"
      popover="auto"
    >
      <menu>
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="
              --justify: space-between;
              --w: 100%;
            "
          >
            New window
          </button>
        </li>
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="
              --justify: space-between;
              --w: 100%;
            "
          >
            Open...
          </button>
        </li>
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="
              --justify: space-between;
              --w: 100%;
            "
          >
            Save
          </button>
        </li>
        <hr data-ui="separator" style="--my: 2" />
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="
              --justify: space-between;
              --w: 100%;
            "
          >
            Close window
          </button>
        </li>
      </menu>
    </div>
  </ui-menu>

  <ui-menu>
    <button
      data-ui="button"
      data-button-variant="ghost"
      type="button"
      popovertarget="menubar-example-edit"
      interestfor="menubar-example-edit"
    >
      Edit
    </button>
    <div
      id="menubar-example-edit"
      data-menu-slot="popover"
      data-popover-side="bottom"
      data-popover-align="start"
      popover="auto"
    >
      <menu>
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="
              --justify: space-between;
              --w: 100%;
            "
          >
            Undo
          </button>
        </li>
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="
              --justify: space-between;
              --w: 100%;
            "
          >
            Redo
          </button>
        </li>
        <hr data-ui="separator" style="--my: 2" />
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="
              --justify: space-between;
              --w: 100%;
            "
          >
            Cut
          </button>
        </li>
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="
              --justify: space-between;
              --w: 100%;
            "
          >
            Copy
          </button>
        </li>
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="
              --justify: space-between;
              --w: 100%;
            "
          >
            Paste
          </button>
        </li>
      </menu>
    </div>
  </ui-menu>

  <ui-menu>
    <button
      data-ui="button"
      data-button-variant="ghost"
      type="button"
      popovertarget="menubar-example-view"
      interestfor="menubar-example-view"
    >
      View
    </button>
    <div
      id="menubar-example-view"
      data-menu-slot="popover"
      data-popover-side="bottom"
      data-popover-align="start"
      popover="auto"
    >
      <menu>
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="
              --justify: space-between;
              --w: 100%;
            "
          >
            Zoom in
          </button>
        </li>
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="
              --justify: space-between;
              --w: 100%;
            "
          >
            Zoom out
          </button>
        </li>
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="
              --justify: space-between;
              --w: 100%;
            "
          >
            Full screen
          </button>
        </li>
      </menu>
    </div>
  </ui-menu>

  <ui-menu>
    <button
      data-ui="button"
      data-button-variant="ghost"
      type="button"
      popovertarget="menubar-example-help"
      interestfor="menubar-example-help"
    >
      Help
    </button>
    <div
      id="menubar-example-help"
      data-menu-slot="popover"
      data-popover-side="bottom"
      data-popover-align="end"
      popover="auto"
    >
      <menu>
        <li>
          <a
            href="#"
            data-ui="button"
            data-button-variant="ghost"
            style="
              --justify: space-between;
              --w: 100%;
            "
            >Documentation</a
          >
        </li>
        <li>
          <a
            href="#"
            data-ui="button"
            data-button-variant="ghost"
            style="
              --justify: space-between;
              --w: 100%;
            "
            >Keyboard shortcuts</a
          >
        </li>
        <li>
          <a
            href="#"
            data-ui="button"
            data-button-variant="ghost"
            style="
              --justify: space-between;
              --w: 100%;
            "
            >Report an issue</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-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 };
<div
  aria-label="Application menu"
  style="
    --display: flex;
    --items: center;
    --p: 2;
    --gap: 1px;
    --w: 100%;
    --bg: var(--color-card);
    --border-b: 1;
  "
>
  <ui-menu>
    <button
      data-ui="button"
      data-button-variant="ghost"
      type="button"
      popovertarget="menubar-search-file"
      interestfor="menubar-search-file"
    >
      File
    </button>
    <div
      id="menubar-search-file"
      data-menu-slot="popover"
      data-popover-side="bottom"
      data-popover-align="start"
      popover="auto"
    >
      <menu>
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="--justify: start"
          >
            New window
          </button>
        </li>
        <li>
          <button
            data-ui="button"
            data-button-variant="ghost"
            type="button"
            style="--justify: start"
          >
            Open…
          </button>
        </li>
      </menu>
    </div>
  </ui-menu>

  <ui-menu>
    <button
      data-ui="button"
      data-button-variant="ghost"
      type="button"
      popovertarget="menubar-search-help"
      interestfor="menubar-search-help"
    >
      Help
    </button>
    <div
      id="menubar-search-help"
      data-menu-slot="popover"
      data-popover-side="bottom"
      data-popover-align="start"
      popover="auto"
    >
      <div
        style="
          --display: flex-col;
          --p: 2;
          --gap: 2;
          --min-w: 20rem;
        "
      >
        <label
          for="menubar-help-topic"
          style="
            --display: inline-flex;
            --font-size: var(--font-size-eyebrow);
            --font-weight: strong;
            --font-weight: var(--font-weight-eyebrow);
            --leading: var(--leading-eyebrow);
            --tracking: var(--tracking-eyebrow);
            --text-transform: uppercase;
            --text: var(--color-muted-foreground);
          "
          >Search help topics</label
        >
        <ui-combobox>
          <input type="hidden" name="help-topic" data-combobox-slot="value" value="" />
          <div data-combobox-slot="control">
            <input
              id="menubar-help-topic"
              type="text"
              placeholder="Find a topic…"
              role="combobox"
              aria-expanded="true"
              aria-autocomplete="list"
              autocomplete="off"
            />
          </div>
          <!-- No [popover] = the inline variant: the list renders in flow, always open -->
          <div data-combobox-slot="panel">
            <ul role="listbox" data-combobox-slot="list" aria-label="Help topics">
              <li
                role="option"
                data-combobox-slot="item"
                data-ui="button"
                data-button-variant="ghost"
                data-combobox-value="shortcuts"
                style="--justify: start"
              >
                Keyboard shortcuts
              </li>
              <li
                role="option"
                data-combobox-slot="item"
                data-ui="button"
                data-button-variant="ghost"
                data-combobox-value="theming"
                style="--justify: start"
              >
                Theming and dark mode
              </li>
              <li
                role="option"
                data-combobox-slot="item"
                data-ui="button"
                data-button-variant="ghost"
                data-combobox-value="updates"
                style="--justify: start"
              >
                Checking for updates
              </li>
            </ul>
            <p data-combobox-slot="empty" style="--text: var(--color-muted-foreground)">
              No topics found.
            </p>
          </div>
        </ui-combobox>
      </div>
    </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-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 };

Attributes

This primitive is a composition of other primitives and utilities: it has no presets, slots, or hooks of its own. Copy its example and adapt it.

Requires: menu, button, separator.