API
Menubar
A row of menus, composed from menu and button.
Installation
pnpm dlx zazz-ui add menubarnpx zazz-ui add menubaryarn dlx zazz-ui add menubarbunx zazz-ui add menubarAlso 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 };Help search
<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.