component
A dropdown action menu: a trigger opens a floating list of actions, links,
checkbox/radio items, grouped sections, and nested submenus. It follows the
WAI-ARIA menu pattern (role="menu" with menuitem /
menuitemcheckbox / menuitemradio / separator, roving focus, and
aria-haspopup="menu" on submenu triggers) — distinct from a value-selecting
ListBox / Select.
Under the hood it reuses the ListBox in its pattern="menu" mode
for every list surface (root and each submenu) and the Popover
positioning layer, adding only the trigger, submenu orchestration, and Apple-style
safe-triangle pointer tracking (a submenu stays open while the cursor arcs
diagonally toward its panel).
Click the trigger (or ↓ / Enter / Space) to open. Arrow keys move the
roving cursor, → opens a submenu, ← / Esc close it, Enter activates.
Hover a submenu trigger to open it — moving diagonally toward the open panel
won’t close it (safe triangle).
---
import Menu from "../menu.astro";
import Stamp from "@pindoba/astro-stamp";
import {
FilePlus,
FolderOpen,
Share2,
Link,
Mail,
Users,
BookOpen,
Trash2,
} from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
import type { MenuNode } from "@pindoba/core-menu";
// NOTE (Astro): trailing <Kbd> shortcuts that the Svelte/React/Vue demos
// render as real components are represented here via the `shortcut` string
// prop instead — Astro's item data is JSON-serialized for hydration, so
// components can't ride along in `items`. Leading icons ARE forwarded, the
// same way rich-items.astro does it: named slots on <Menu> keyed
// `<id>:leading`, which Menu resolves per level (including nested submenus)
// via `optionSlotsHtml`. Checkbox/radio items already render a built-in
// check indicator, so they're left without a leading icon here. Checked/
// selected state is conveyed via aria-checked (server-rendered) and
// reflected in the status line below via events.
const items: MenuNode[] = [
{ type: "action", id: "new", label: "New file", shortcut: "⌘N" },
{ type: "action", id: "open", label: "Open…", shortcut: "⌘O" },
{ type: "separator", id: "s1" },
{
type: "group",
id: "view",
label: "View",
items: [
{ type: "checkbox", id: "wrap", label: "Word wrap", checked: true },
{ type: "checkbox", id: "minimap", label: "Minimap", checked: false },
],
},
{ type: "separator", id: "s2" },
{
type: "radiogroup",
id: "sort",
label: "Sort by",
value: "name",
items: [
{ type: "radio", id: "sort-name", label: "Name", value: "name" },
{ type: "radio", id: "sort-date", label: "Date modified", value: "date" },
{ type: "radio", id: "sort-size", label: "Size", value: "size" },
],
},
{ type: "separator", id: "s3" },
{
type: "submenu",
id: "share",
label: "Share",
items: [
{ type: "action", id: "copy", label: "Copy link" },
{ type: "action", id: "email", label: "Email" },
{
type: "submenu",
id: "social",
label: "Social",
items: [
{ type: "action", id: "x", label: "X / Twitter" },
{ type: "action", id: "ln", label: "LinkedIn" },
],
},
],
},
{ type: "separator", id: "s4" },
{
type: "link",
id: "docs",
label: "Documentation",
href: "https://pindoba.js.org",
target: "_blank",
},
{ type: "action", id: "del", label: "Delete", feedback: "danger" },
];
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Menu id="astro-menu-default" items={items} aria-label="File actions">
Actions
<Stamp slot="new:leading" size="sm" emphasis="ghost"><FilePlus /></Stamp>
<Stamp slot="open:leading" size="sm" emphasis="ghost"><FolderOpen /></Stamp>
<Stamp slot="share:leading" size="sm" emphasis="ghost"><Share2 /></Stamp>
<Stamp slot="copy:leading" size="sm" emphasis="ghost"><Link /></Stamp>
<Stamp slot="email:leading" size="sm" emphasis="ghost"><Mail /></Stamp>
<Stamp slot="social:leading" size="sm" emphasis="ghost"><Users /></Stamp>
<Stamp slot="docs:leading" size="sm" emphasis="ghost"><BookOpen /></Stamp>
<Stamp slot="del:leading" size="sm" emphasis="ghost"><Trash2 /></Stamp>
</Menu>
<p id="astro-menu-default-status">
Word wrap: on · Minimap: off · Sort: name · Last action: —
</p>
</div>
<script>
const LABELS: Record<string, string> = {
new: "New file",
open: "Open",
copy: "Copy link",
email: "Email",
x: "X",
ln: "LinkedIn",
docs: "Documentation",
del: "Delete",
};
function initDefaultMenuDemo() {
const root = document.getElementById("astro-menu-default");
const status = document.getElementById("astro-menu-default-status");
if (!root || !status || root.dataset.demoWired) return;
root.dataset.demoWired = "true";
const state = { wrap: true, minimap: false, sort: "name", last: "—" };
const paint = () => {
status.textContent = `Word wrap: ${state.wrap ? "on" : "off"} · Minimap: ${
state.minimap ? "on" : "off"
} · Sort: ${state.sort} · Last action: ${state.last}`;
};
root.addEventListener("pindoba:menu:action", (e) => {
const { id } = (e as CustomEvent<{ id: string }>).detail;
state.last = LABELS[id] ?? id;
paint();
});
root.addEventListener("pindoba:menu:checkedchange", (e) => {
const { id, checked } = (
e as CustomEvent<{ id: string; checked: boolean }>
).detail;
if (id === "wrap") state.wrap = checked;
if (id === "minimap") state.minimap = checked;
paint();
});
root.addEventListener("pindoba:menu:valuechange", (e) => {
const { groupId, value } = (
e as CustomEvent<{ groupId: string; value: string }>
).detail;
if (groupId === "sort") state.sort = value;
paint();
});
paint();
}
initDefaultMenuDemo();
document.addEventListener("astro:page-load", initDefaultMenuDemo);
</script>menuitemcheckbox rows toggle independently and stay open by default so you
can flip several in one visit; menuitemradio rows inside a radiogroup
enforce single selection within that group. Both mirror their state onto
aria-checked.
---
import Menu from "../menu.astro";
import { stack } from "@pindoba/styled-system/patterns";
import type { MenuNode } from "@pindoba/core-menu";
// NOTE (Astro): the Svelte demo's leading <Check> / spacer snippets are omitted
// (see default.astro). Checked/selected state is server-rendered via
// aria-checked and mirrored into the status line below via events.
const items: MenuNode[] = [
{
type: "group",
id: "view",
label: "View",
items: [
{ type: "checkbox", id: "wrap", label: "Word wrap", checked: true },
{ type: "checkbox", id: "minimap", label: "Minimap", checked: false },
],
},
{ type: "separator", id: "s1" },
{
type: "radiogroup",
id: "sort",
label: "Sort by",
value: "name",
items: [
{ type: "radio", id: "sort-name", label: "Name", value: "name" },
{ type: "radio", id: "sort-date", label: "Date modified", value: "date" },
{ type: "radio", id: "sort-size", label: "Size", value: "size" },
],
},
];
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Menu id="astro-menu-checkbox-radio" items={items} aria-label="View options">
View options
</Menu>
<p id="astro-menu-checkbox-radio-status">
Word wrap: on · Minimap: off · Sort: name
</p>
</div>
<script>
function initCheckboxRadioMenuDemo() {
const root = document.getElementById("astro-menu-checkbox-radio");
const status = document.getElementById("astro-menu-checkbox-radio-status");
if (!root || !status || root.dataset.demoWired) return;
root.dataset.demoWired = "true";
const state = { wrap: true, minimap: false, sort: "name" };
const paint = () => {
status.textContent = `Word wrap: ${state.wrap ? "on" : "off"} · Minimap: ${
state.minimap ? "on" : "off"
} · Sort: ${state.sort}`;
};
root.addEventListener("pindoba:menu:checkedchange", (e) => {
const { id, checked } = (
e as CustomEvent<{ id: string; checked: boolean }>
).detail;
if (id === "wrap") state.wrap = checked;
if (id === "minimap") state.minimap = checked;
paint();
});
root.addEventListener("pindoba:menu:valuechange", (e) => {
const { groupId, value } = (
e as CustomEvent<{ groupId: string; value: string }>
).detail;
if (groupId === "sort") state.sort = value;
paint();
});
paint();
}
initCheckboxRadioMenuDemo();
document.addEventListener("astro:page-load", initCheckboxRadioMenuDemo);
</script>Hover a submenu trigger — or press → while it’s active — to open its panel;
← or Esc closes just that level, leaving parent levels open. Moving the
pointer diagonally toward an open submenu’s panel keeps it open even while
crossing sibling rows (the safe triangle), and each submenu panel overlaps its
parent slightly so the two stay visually connected.
---
import Menu from "../menu.astro";
import { stack } from "@pindoba/styled-system/patterns";
import type { MenuNode } from "@pindoba/core-menu";
const items: MenuNode[] = [
{ type: "action", id: "duplicate", label: "Duplicate" },
{ type: "separator", id: "s1" },
{
type: "submenu",
id: "share",
label: "Share",
items: [
{ type: "action", id: "copy", label: "Copy link" },
{ type: "action", id: "email", label: "Email" },
{
type: "submenu",
id: "social",
label: "Social",
items: [
{ type: "action", id: "x", label: "X / Twitter" },
{ type: "action", id: "ln", label: "LinkedIn" },
],
},
],
},
];
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Menu id="astro-menu-submenu" items={items} aria-label="Item actions">
Actions
</Menu>
<p id="astro-menu-submenu-status">Last action: —</p>
</div>
<script>
const LABELS: Record<string, string> = {
duplicate: "Duplicate",
copy: "Copy link",
email: "Email",
x: "X / Twitter",
ln: "LinkedIn",
};
function initSubmenuMenuDemo() {
const root = document.getElementById("astro-menu-submenu");
const status = document.getElementById("astro-menu-submenu-status");
if (!root || !status || root.dataset.demoWired) return;
root.dataset.demoWired = "true";
root.addEventListener("pindoba:menu:action", (e) => {
const { id } = (e as CustomEvent<{ id: string }>).detail;
status.textContent = `Last action: ${LABELS[id] ?? id}`;
});
}
initSubmenuMenuDemo();
document.addEventListener("astro:page-load", initSubmenuMenuDemo);
</script>Set feedback: "danger" on an action item to style it as destructive — useful
for irreversible operations like deleting a resource.
---
import Menu from "../menu.astro";
import { stack } from "@pindoba/styled-system/patterns";
import type { MenuNode } from "@pindoba/core-menu";
// The trailing ⌫ <Kbd> from the Svelte demo is omitted (see default.astro);
// the danger tone is driven server-side via the item's `feedback: "danger"`.
const items: MenuNode[] = [
{ type: "action", id: "rename", label: "Rename" },
{ type: "action", id: "duplicate", label: "Duplicate" },
{ type: "separator", id: "s1" },
{
type: "action",
id: "delete",
label: "Delete…",
feedback: "danger",
shortcut: "⌫",
},
];
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Menu id="astro-menu-danger" items={items} aria-label="Item actions">
Actions
</Menu>
<p id="astro-menu-danger-status">Last action: —</p>
</div>
<script>
const LABELS: Record<string, string> = {
rename: "Rename",
duplicate: "Duplicate",
delete: "Delete…",
};
function initDangerMenuDemo() {
const root = document.getElementById("astro-menu-danger");
const status = document.getElementById("astro-menu-danger-status");
if (!root || !status || root.dataset.demoWired) return;
root.dataset.demoWired = "true";
root.addEventListener("pindoba:menu:action", (e) => {
const { id } = (e as CustomEvent<{ id: string }>).detail;
status.textContent = `Last action: ${LABELS[id] ?? id}`;
});
}
initDangerMenuDemo();
document.addEventListener("astro:page-load", initDangerMenuDemo);
</script>open is bindable, so a consumer can drive it from outside the component (a
separate trigger, a keyboard shortcut, etc.). onOpenChange fires whenever the
open state changes, whatever triggered it.
---
import Menu from "../menu.astro";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";
import type { MenuNode } from "@pindoba/core-menu";
// The open state is driven externally via the imperative `getMenu(root)` handle
// (Astro's equivalent of Svelte's bindable `open`); the menu also emits
// `pindoba:menu:openchange` so external UI can mirror it.
const items: MenuNode[] = [
{ type: "action", id: "new", label: "New file" },
{ type: "action", id: "open", label: "Open…" },
{ type: "action", id: "close", label: "Close" },
];
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Button id="astro-menu-controlled-toggle" emphasis="secondary">
Toggle externally
</Button>
<Menu id="astro-menu-controlled" items={items} aria-label="File actions">
Actions
</Menu>
<p id="astro-menu-controlled-status">Menu is closed · Last action: —</p>
</div>
<script>
import { getMenu } from "../runtime";
const LABELS: Record<string, string> = {
new: "New file",
open: "Open",
close: "Close",
};
function initControlledMenuDemo() {
const root = document.getElementById("astro-menu-controlled");
const toggle = document.getElementById("astro-menu-controlled-toggle");
const status = document.getElementById("astro-menu-controlled-status");
if (!root || !toggle || !status || root.dataset.demoWired) return;
root.dataset.demoWired = "true";
const state = { open: false, last: "—" };
const paint = () => {
status.textContent = `Menu is ${
state.open ? "open" : "closed"
} · Last action: ${state.last}`;
};
toggle.addEventListener("click", () => getMenu(root)?.toggle());
root.addEventListener("pindoba:menu:openchange", (e) => {
state.open = (e as CustomEvent<{ open: boolean }>).detail.open;
paint();
});
root.addEventListener("pindoba:menu:action", (e) => {
const { id } = (e as CustomEvent<{ id: string }>).detail;
state.last = LABELS[id] ?? id;
paint();
});
paint();
}
initControlledMenuDemo();
document.addEventListener("astro:page-load", initControlledMenuDemo);
</script>Menu items render through ListBox’s Banner, so an action item can
carry a description (the Banner subheading) alongside its label, plus
leading and trailing content — icons via Stamp, a
Badge, or a keyboard shortcut. Svelte, React, and Vue pass real
components/snippets/nodes for leading and trailing in items; Astro takes
the same content through per-item named slots — the same convention
ListBox itself uses — keyed <itemId>:leading /
<itemId>:trailing, e.g. <Stamp slot="acme:leading">…</Stamp> as a child of
<Menu>. Menu resolves its own slots and forwards them to whichever nested
ListBox level actually renders that item, so it works for items inside
submenus too.
---
import Menu from "../menu.astro";
import Stamp from "@pindoba/astro-stamp";
import Badge from "@pindoba/astro-badge";
import { Building2, Globe, Plus } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
import type { MenuNode } from "@pindoba/core-menu";
// Each item's content renders through ListBox's Banner, same as Svelte/React/
// Vue — Astro just authors it via per-item NAMED slots (`<id>:leading` /
// `<id>:trailing`) instead of passing components through `items` (Astro's
// item data is JSON-serialized for hydration, so components can't ride
// along). Menu forwards its own resolved slots down to whichever nested
// ListBox level actually renders that item — see `menu.astro`'s
// `optionSlotsHtml` — so this works identically for a submenu's items, not
// just the root level.
const items: MenuNode[] = [
{
type: "action",
id: "acme",
label: "Acme Inc",
description: "12 members",
},
{
type: "action",
id: "globex",
label: "Globex Corp",
description: "48 members",
},
{ type: "separator", id: "s1" },
{
type: "submenu",
id: "more",
label: "More workspaces",
items: [
{
type: "action",
id: "initech",
label: "Initech",
description: "8 members",
},
{
type: "action",
id: "umbrella",
label: "Umbrella Corp",
description: "120 members",
},
],
},
{ type: "separator", id: "s2" },
{ type: "action", id: "create", label: "Create workspace…" },
];
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Menu id="astro-menu-rich-items" items={items} aria-label="Switch workspace">
Switch workspace
<Stamp slot="acme:leading" size="md"><Building2 /></Stamp>
<Stamp slot="globex:leading" size="md"><Globe /></Stamp>
<Badge slot="globex:trailing" size="sm" feedback="success">Pro</Badge>
<Stamp slot="more:leading" size="md"><Building2 /></Stamp>
<Stamp slot="initech:leading" size="md"><Building2 /></Stamp>
<Stamp slot="umbrella:leading" size="md"><Globe /></Stamp>
<Stamp slot="create:leading" size="md"><Plus /></Stamp>
</Menu>
<p id="astro-menu-rich-items-status">Last action: —</p>
</div>
<script>
const LABELS: Record<string, string> = {
acme: "Acme Inc",
globex: "Globex Corp",
initech: "Initech",
umbrella: "Umbrella Corp",
create: "Create workspace…",
};
function initRichItemsMenuDemo() {
const root = document.getElementById("astro-menu-rich-items");
const status = document.getElementById("astro-menu-rich-items-status");
if (!root || !status || root.dataset.demoWired) return;
root.dataset.demoWired = "true";
const state = { last: "—" };
const paint = () => {
status.textContent = `Last action: ${state.last}`;
};
root.addEventListener("pindoba:menu:action", (e) => {
const { id } = (e as CustomEvent<{ id: string }>).detail;
state.last = LABELS[id] ?? id;
paint();
});
paint();
}
initRichItemsMenuDemo();
document.addEventListener("astro:page-load", initRichItemsMenuDemo);
</script>A type: "custom" node drops non-interactive content anywhere in the menu —
an account header, a footer info row, a shortcut legend. It renders
role="presentation" and is skipped by keyboard navigation and typeahead, so
the menu keeps valid role="menu" semantics; arrow keys move straight past it
to the real items. Content rides the same per-framework path as leading /
trailing: Svelte/React/Vue pass a snippet / node / VNode in content; Astro
authors it as a named slot keyed by the node id (e.g. <Fragment slot="account">).
Keep custom blocks presentational — put focusable controls in real menu items,
not inside a custom block.
The example below reproduces a typical account menu: a header block with the signed-in identity, a separator, then a destructive Sair action.
---
import Menu from "../menu.astro";
import Stamp from "@pindoba/astro-stamp";
import { LogOut, ChevronDown } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
import type { MenuNode } from "@pindoba/core-menu";
// The non-interactive account header is a `type:"custom"` node. Astro can't
// carry components in `items` (they're JSON-serialized for hydration), so the
// content is authored as a NAMED slot keyed by the node id (`account`) — the
// same convention the rich-item `<id>:leading` slots use. Menu forwards it down
// to the ListBox via `customSlotsHtml`. The block is skipped by keyboard
// navigation, so arrows land straight on "Sair".
const items: MenuNode[] = [
{ type: "custom", id: "account", "aria-label": "Signed in as Demo Cubos" },
{ type: "separator", id: "s1" },
{ type: "action", id: "sign-out", label: "Sair", feedback: "danger" },
];
const nameClass = css({ fontWeight: "bold", color: "colorPalette.text.bold" });
const emailClass = css({ fontSize: "sm", color: "colorPalette.text.muted" });
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Menu id="astro-menu-account" items={items} aria-label="Account menu">
Demo Cubos
<ChevronDown />
<Fragment slot="account">
<span class={nameClass}>Demo Cubos</span>
<span class={emailClass}>demo@cubos.dev</span>
</Fragment>
<Stamp slot="sign-out:leading" emphasis="ghost"><LogOut /></Stamp>
</Menu>
<p id="astro-menu-account-status">Last action: —</p>
</div>
<script>
function initAccountMenuDemo() {
const root = document.getElementById("astro-menu-account");
const status = document.getElementById("astro-menu-account-status");
if (!root || !status || root.dataset.demoWired) return;
root.dataset.demoWired = "true";
root.addEventListener("pindoba:menu:action", (e) => {
const { id } = (e as CustomEvent<{ id: string }>).detail;
status.textContent = `Last action: ${id === "sign-out" ? "Sair" : id}`;
});
}
initAccountMenuDemo();
document.addEventListener("astro:page-load", initAccountMenuDemo);
</script>A group’s header can also be rich content instead of a plain label: set
header on the group node (Svelte/React/Vue) — it wins over label and renders
in the section’s header slot, letting a group title carry an icon or its own
type styling.
Accessible label for the menu surface when there is no visible labelling.
Stable id root (auto-generated when omitted).
The menu's content as a data array. Mutually exclusive with slot composition (`<MenuItem>` etc.); if both are supplied the array wins and a dev-time warning is emitted.
Fired when the open state changes.
falseWhether the menu is open. Bindable in Svelte.
Per-slot style / attribute escape hatch.
"bottom-start"Preferred placement of the menu panel relative to its trigger.
"md"Visual size.
The disclosure trigger. Receives the merged trigger bag (Popover wiring + the menu's `aria-haspopup` / `aria-expanded`); spread it onto your own element (typically a `<Button>`).