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).
Word wrap: on · Minimap: off · Sort: name · Last action: —
---
// GENERATED from packages/demos/menu/default.demo.tsx — edit the source, not this file.
import Menu from "@pindoba/astro-menu";
import Stamp from "@pindoba/astro-stamp";
import {
FilePlus,
FolderOpen,
Share2,
Link,
Mail,
Users,
BookOpen,
Trash,
} from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
const lastAction = "—";
const wrap = true;
const minimap = false;
const sort = "name";
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="menu/default"
>
<Menu
items={[
{
type: "action",
id: "new",
label: "New file",
shortcut: "mod+N",
},
{
type: "action",
id: "open",
label: "Open…",
shortcut: "mod+O",
},
{ type: "separator", id: "s1" },
{
type: "group",
id: "view",
label: "View",
items: [
{
type: "checkbox",
id: "wrap",
label: "Word wrap",
checked: wrap,
},
{
type: "checkbox",
id: "minimap",
label: "Minimap",
checked: minimap,
},
],
},
{ type: "separator", id: "s2" },
{
type: "radiogroup",
id: "sort",
label: "Sort by",
value: sort,
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",
tone: "danger",
shortcut: "⌫",
},
]}
aria-label="File actions"
triggerEmphasis="secondary"
data-ir="1"
>
<Stamp slot="new:leading" size="sm" emphasis="ghost">
<FilePlus size={16} />
</Stamp>
<Stamp slot="open:leading" size="sm" emphasis="ghost">
<FolderOpen size={16} />
</Stamp>
<Stamp slot="share:leading" size="sm" emphasis="ghost">
<Share2 size={16} />
</Stamp>
<Stamp slot="copy:leading" size="sm" emphasis="ghost">
<Link size={16} />
</Stamp>
<Stamp slot="email:leading" size="sm" emphasis="ghost">
<Mail size={16} />
</Stamp>
<Stamp slot="social:leading" size="sm" emphasis="ghost">
<Users size={16} />
</Stamp>
<Stamp slot="docs:leading" size="sm" emphasis="ghost">
<BookOpen size={16} />
</Stamp>
<Stamp slot="del:leading" size="sm" emphasis="ghost">
<Trash size={16} />
</Stamp>
Actions
</Menu>
<p>
Word wrap: <!--ir:3-->
{wrap ? "on" : "off"}
<!--/ir--> · Minimap: <!--ir:4-->
{minimap ? "on" : "off"}
<!--/ir--> · Sort: <!--ir:5-->
{sort}
<!--/ir--> · Last action: <!--ir:6-->
{lastAction}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/default.ts";
document
.querySelectorAll('[data-ir-root="menu/default"]')
.forEach((root) => hydrate([root]));
</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.
The check mark sits at the end of the row by default, so a checkable
section’s labels share the column of every other section’s — nothing is
indented to make room for a glyph only some rows draw. Set
checkPlacement="start" to put it in a leading column instead; submenus
inherit the side from their root.
Word wrap: on · Minimap: off · Label: mixed · Sort: name
---
// GENERATED from packages/demos/menu/checkbox-radio.demo.tsx — edit the source, not this file.
import Menu from "@pindoba/astro-menu";
import { stack } from "@pindoba/styled-system/patterns";
const wrap = true;
const minimap = false;
const label = "mixed" as boolean | "mixed";
const sort = "name";
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="menu/checkbox-radio"
>
<Menu
items={[
{
type: "group",
id: "view",
label: "View",
items: [
{
type: "checkbox",
id: "wrap",
label: "Word wrap",
checked: wrap,
},
{
type: "checkbox",
id: "minimap",
label: "Minimap",
checked: minimap,
},
{
type: "checkbox",
id: "label",
label: "Label: Receipts (some selected)",
checked: label,
},
],
},
{ type: "separator", id: "s1" },
{
type: "radiogroup",
id: "sort",
label: "Sort by",
value: sort,
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",
},
],
},
]}
aria-label="View options"
triggerEmphasis="secondary"
data-ir="1"
>
View options
</Menu>
<p>
Word wrap: <!--ir:3-->
{wrap ? "on" : "off"}
<!--/ir--> · Minimap: <!--ir:4-->
{minimap ? "on" : "off"}
<!--/ir--> · Label: <!--ir:5-->
{label === "mixed" ? "mixed" : label ? "on" : "off"}
<!--/ir--> · Sort: <!--ir:6-->
{sort}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/checkbox-radio.ts";
document
.querySelectorAll('[data-ir-root="menu/checkbox-radio"]')
.forEach((root) => hydrate([root]));
</script>By default a checked row is a quiet recessed well, found by its check mark.
activeEmphasis="primary" fills it with the accent surface and flips its label
and description onto the contrast ramp, so the current option is legible at a
glance — worth it when the menu exists to show a current choice (a workspace
switcher, a sort order) rather than to toggle things.
Loudness and color are separate axes. activeEmphasis says how loud the state
is — "primary" fills the row, "secondary" (the default) is the quiet recess,
"tertiary" the colorless one. activeTone says which palette the filled
row paints with, and by default it follows the list, so a tone="danger"
list gets a red chosen row.
Set them apart when they should disagree, which is the usual case for a current-item marker: the menu below stays neutral — every other row keeps the hover and muted text it always had — while its current row is brand blue.
To re-theme the whole list instead, there is listboxTone.
Once the row is filled, the check glyph is usually redundant — the fill already
says “chosen”. showCheck={false} drops it and the column it reserves, so every
label sits flush and nothing shifts when the choice moves. It is purely visual
and independent of the other two: aria-checked still reports the state, and
you can drop the glyph on a quiet menu just as well.
Checkbox and radio rows also take a description, the same secondary line an
action row has — useful when the label alone doesn’t say which thing it is.
Current workspace: acme
---
// GENERATED from packages/demos/menu/active-emphasis.demo.tsx — edit the source, not this file.
import Menu from "@pindoba/astro-menu";
import { stack } from "@pindoba/styled-system/patterns";
const workspace = "acme";
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="menu/active-emphasis"
>
<Menu
items={[
{
type: "radiogroup",
id: "workspace",
label: "Workspace",
value: workspace,
items: [
{
type: "radio",
id: "ws-acme",
label: "Acme",
value: "acme",
description: "acme.example.com",
},
{
type: "radio",
id: "ws-globex",
label: "Globex",
value: "globex",
description: "globex.example.com",
},
{
type: "radio",
id: "ws-initech",
label: "Initech",
value: "initech",
description: "initech.example.com",
},
],
},
]}
activeEmphasis="primary"
activeTone="primary"
showCheck={false}
aria-label="Switch workspace"
triggerEmphasis="secondary"
data-ir="1"
>
Workspace
</Menu>
<p>
Current workspace: <!--ir:3-->
{workspace}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/active-emphasis.ts";
document
.querySelectorAll('[data-ir-root="menu/active-emphasis"]')
.forEach((root) => hydrate([root]));
</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.
Last action: —
---
// GENERATED from packages/demos/menu/submenu.demo.tsx — edit the source, not this file.
import Menu from "@pindoba/astro-menu";
import { stack } from "@pindoba/styled-system/patterns";
const lastAction = "—";
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="menu/submenu"
>
<Menu
items={[
{
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",
},
],
},
],
},
]}
aria-label="Item actions"
triggerEmphasis="secondary"
data-ir="1"
>
Actions
</Menu>
<p>
Last action: <!--ir:3-->
{lastAction}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/submenu.ts";
document
.querySelectorAll('[data-ir-root="menu/submenu"]')
.forEach((root) => hydrate([root]));
</script>Set search to add a search field at the top of the panel. It stays in place
while the list below it scrolls, and spans the panel’s full width. Pass true
for the defaults, or an object:
placeholder (default "Search…")aria-label (falls back to placeholder, then "Search")emptyLabel (default "No results")filter: true uses the built-in fuzzy matcher, a function
(item, query) => score ranks results yourself, and false leaves
filtering to you (read query and pass filtered items).Items can also take keywords (synonyms such as ["prettier", "beautify"])
that match in addition to their label. The query is controllable with query
and onQueryChange (Svelte: bind:query). It resets to "" every time the
menu closes. Astro reports changes through the pindoba:menu:querychange event
and the handle’s getQuery() / setQuery(). In Astro, filter accepts only
true or false, because a function can’t be sent to the browser.
When the query is empty, the menu looks the same as without search. Once you
type, it becomes one flat, ranked list without section titles or
separators, like a command palette. Results come from the root level and
every submenu. A match inside a submenu shows its path above the label (for
example Share › Email). Activating it works exactly as it does inside the
submenu: same onAction / onCheckedChange, same close-on-select. A submenu
trigger that matches still opens its submenu. Disabled items stay in the list,
still disabled. Custom content blocks are hidden while a query is active. When
nothing matches, a role="status" message replaces the list (outside
role="menu", so the menu never renders empty).
Keyboard:
↓ / ↑ move through the results, and Enter activates the highlighted
row. Focus stays in the field the whole time. The field points at the
highlighted row with aria-activedescendant and controls the list with
aria-controls.→ on a submenu row (with the caret at the end of the text) opens the
submenu and moves focus into it. ← or Esc inside it brings focus back to
the field.Esc clears a non-empty query first, then closes the menu.Home / End move the text caret, and type-to-select is turned off while
the field has focus.Query: “” · Last action: —
---
// GENERATED from packages/demos/menu/search.demo.tsx — edit the source, not this file.
import Menu from "@pindoba/astro-menu";
import { stack } from "@pindoba/styled-system/patterns";
const query = "";
const lastAction = "—";
const wordWrap = true;
const minimap = false;
const items = [
{
type: "group" as const,
id: "commands",
label: "Commands",
items: [
{
type: "action" as const,
id: "command-palette",
label: "Show all commands",
shortcut: "Shift+mod+P",
},
{
type: "action" as const,
id: "format",
label: "Format document",
keywords: ["prettier", "beautify"],
shortcut: "⇧⌥F",
},
{
type: "action" as const,
id: "rename",
label: "Rename symbol",
keywords: ["refactor"],
shortcut: "F2",
},
{
type: "submenu" as const,
id: "share",
label: "Share",
items: [
{
type: "action" as const,
id: "share-email",
label: "Email",
keywords: ["mail", "send"],
},
{
type: "action" as const,
id: "share-link",
label: "Copy link",
keywords: ["url", "clipboard"],
shortcut: "Alt+mod+C",
},
],
},
],
},
{
type: "group" as const,
id: "view",
label: "View",
items: [
{
type: "checkbox" as const,
id: "word-wrap",
label: "Word wrap",
checked: wordWrap,
shortcut: "⌥Z",
},
{
type: "checkbox" as const,
id: "minimap",
label: "Minimap",
checked: minimap,
},
{
type: "action" as const,
id: "zen",
label: "Zen mode",
keywords: ["focus", "distraction free"],
shortcut: "mod+K Z",
},
],
},
{
type: "group" as const,
id: "git",
label: "Git",
items: [
{
type: "action" as const,
id: "git-commit",
label: "Commit",
shortcut: "mod+Enter",
},
{
type: "action" as const,
id: "git-pull",
label: "Pull",
keywords: ["fetch", "sync"],
},
{
type: "action" as const,
id: "git-push",
label: "Push",
keywords: ["sync", "upload"],
},
{
type: "action" as const,
id: "git-stash",
label: "Stash changes",
disabled: true,
},
],
},
];
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="menu/search"
>
<Menu
items={items}
search={{ placeholder: "Search commands…", emptyLabel: "No commands" }}
aria-label="Editor commands"
triggerEmphasis="secondary"
data-ir="1"
>
Commands
</Menu>
<p>
Query: “<!--ir:3-->
{query}
<!--/ir-->” · Last action: <!--ir:4-->
{lastAction}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/search.ts";
document
.querySelectorAll('[data-ir-root="menu/search"]')
.forEach((root) => hydrate([root]));
</script>submenuMode="drill" opens submenus in place instead of as flyouts beside
their row: the panel’s content is replaced by the submenu’s items, and a header
with a back button and the level’s title leads the list — a stack of pages, as
on touch screens. It suits menus whose submenus are long lists of values (a
status, a person, a destination) and menus that live near a screen edge, where
a flyout has nowhere to go. The items are the same as for flyouts.
Keyboard:
→ or Enter on a submenu row drills in; ← or Esc goes back one level
(Esc at the root closes the menu).With search, the search field is the panel’s header: while drilled in, the
back button leads it and the placeholder names the level (“Search Move to…”).
The search looks only inside the level on screen — its rows and their
nested submenus — not the whole tree, so typing after drilling into a status
list filters the statuses. Drilling clears the query, and Backspace in an
empty field goes back a level.
Motion. A drill driven by the pointer animates, briefly: the new level’s rows enter from the side the drill came from (deeper from the right, back from the left), the panel morphs to the new level’s size, and the header follows — the title enters with the rows, the back button fades in and the search field slides over to make room for it. Nothing waits on an exit: the old level leaves at once. A drill driven by the keyboard is instant, so arrowing through levels never waits on an animation. Under reduced motion only the fades remain.
The drilled path is state: path (submenu ids, root first) with
onPathChange (Svelte: bind:path; Astro: the pindoba:menu:pathchange
event). It resets to [] whenever the menu closes. drillLabels translates the
back button’s names ("Back", "Back to {label}") and the per-level search
placeholder ("Search {label}…").
Level: root · Last action: —
---
// GENERATED from packages/demos/menu/drill.demo.tsx — edit the source, not this file.
import Menu from "@pindoba/astro-menu";
import { stack } from "@pindoba/styled-system/patterns";
const lastAction = "—";
const path = "root";
const items = [
{
type: "submenu" as const,
id: "status",
label: "Set status",
items: [
{
type: "action" as const,
id: "status-todo",
label: "To do",
},
{
type: "action" as const,
id: "status-active",
label: "In progress",
},
{
type: "action" as const,
id: "status-review",
label: "In review",
},
{
type: "action" as const,
id: "status-closed",
label: "Closed",
},
],
},
{
type: "submenu" as const,
id: "assign",
label: "Assign to",
items: [
{
type: "action" as const,
id: "assign-me",
label: "Me",
},
{
type: "action" as const,
id: "assign-ines",
label: "Inês Duarte",
},
{
type: "action" as const,
id: "assign-kofi",
label: "Kofi Mensah",
},
{
type: "action" as const,
id: "assign-yuna",
label: "Yuna Choi",
},
],
},
{
type: "submenu" as const,
id: "move",
label: "Move to",
items: [
{
type: "submenu" as const,
id: "move-studio",
label: "Studio workspace",
items: [
{
type: "action" as const,
id: "move-studio-brand",
label: "Brand refresh",
},
{
type: "action" as const,
id: "move-studio-site",
label: "Marketing site",
},
],
},
{
type: "submenu" as const,
id: "move-ops",
label: "Operations workspace",
items: [
{
type: "action" as const,
id: "move-ops-hiring",
label: "Hiring plan",
},
{
type: "action" as const,
id: "move-ops-budget",
label: "Annual budget",
},
],
},
],
},
{ type: "separator" as const, id: "s1" },
{
type: "action" as const,
id: "duplicate",
label: "Duplicate",
},
{
type: "action" as const,
id: "delete",
label: "Delete",
tone: "danger" as const,
},
];
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="menu/drill"
>
<Menu
items={items}
submenuMode="drill"
search={{ placeholder: "Search actions…", emptyLabel: "No actions" }}
aria-label="Task actions"
triggerEmphasis="secondary"
data-ir="1"
>
Task actions
</Menu>
<p>
Level: <!--ir:3-->
{path}
<!--/ir--> · Last action: <!--ir:4-->
{lastAction}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/drill.ts";
document
.querySelectorAll('[data-ir-root="menu/drill"]')
.forEach((root) => hydrate([root]));
</script>columns (1 to 4) lays the rows out in a grid. Columns size to their
content (with a minimum width), every group shares the same columns, and group
titles, separators and custom blocks span the full width, so sections still
read top to bottom. Rows fill each line left to right. Search results always
list in one ranked column, and flyout submenus keep one column. Use it for a
short menu of many small choices — inserting a block, picking a tag — where one
long column would scroll. The Bulk Bar’s More menu combines both:
two columns and drill-in submenus.
Arrow keys move through the grid as it is laid out:
← / → move across the columns of a line.↑ / ↓ stay in the same column, moving to the line above or below (across
group titles and separators).→ on a submenu row drills in (or opens its flyout) and ←
goes back a level (or closes the submenu).Last action: —
---
// GENERATED from packages/demos/menu/columns.demo.tsx — edit the source, not this file.
import Menu from "@pindoba/astro-menu";
import { stack } from "@pindoba/styled-system/patterns";
const lastAction = "—";
const items = [
{
type: "group" as const,
id: "insert",
label: "Insert",
items: [
{
type: "action" as const,
id: "heading",
label: "Heading",
},
{
type: "action" as const,
id: "paragraph",
label: "Paragraph",
},
{
type: "action" as const,
id: "checklist",
label: "Checklist",
},
{
type: "action" as const,
id: "quote",
label: "Quote",
},
{
type: "action" as const,
id: "divider",
label: "Divider",
},
{
type: "action" as const,
id: "callout",
label: "Callout",
},
],
},
{ type: "separator" as const, id: "s1" },
{
type: "group" as const,
id: "media",
label: "Media",
items: [
{
type: "action" as const,
id: "image",
label: "Image",
},
{
type: "action" as const,
id: "video",
label: "Video",
},
{
type: "action" as const,
id: "file",
label: "File",
},
{
type: "action" as const,
id: "embed",
label: "Embed",
},
],
},
];
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="menu/columns"
>
<Menu
items={items}
columns={2}
aria-label="Insert block"
triggerEmphasis="secondary"
data-ir="1"
>
Insert block
</Menu>
<p>
Last action: <!--ir:3-->
{lastAction}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/columns.ts";
document
.querySelectorAll('[data-ir-root="menu/columns"]')
.forEach((root) => hydrate([root]));
</script>Set tone: "danger" on an action item to style it as destructive — useful
for irreversible operations like deleting a resource.
Last action: —
---
// GENERATED from packages/demos/menu/danger.demo.tsx — edit the source, not this file.
import Menu from "@pindoba/astro-menu";
import { stack } from "@pindoba/styled-system/patterns";
const lastAction = "—";
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="menu/danger"
>
<Menu
items={[
{
type: "action",
id: "rename",
label: "Rename",
},
{
type: "action",
id: "duplicate",
label: "Duplicate",
},
{ type: "separator", id: "s1" },
{
type: "action",
id: "delete",
label: "Delete…",
tone: "danger",
shortcut: "⌫",
},
]}
aria-label="Item actions"
triggerEmphasis="secondary"
data-ir="1"
>
Actions
</Menu>
<p>
Last action: <!--ir:3-->
{lastAction}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/danger.ts";
document
.querySelectorAll('[data-ir-root="menu/danger"]')
.forEach((root) => hydrate([root]));
</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.
Menu is closed · Last action: —
---
// GENERATED from packages/demos/menu/controlled.demo.tsx — edit the source, not this file.
import Menu from "@pindoba/astro-menu";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";
const open = false;
const lastAction = "—";
const items = [
{
type: "action" as const,
id: "new",
label: "New file",
},
{
type: "action" as const,
id: "open",
label: "Open…",
},
{
type: "action" as const,
id: "close",
label: "Close",
},
];
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="menu/controlled"
>
<Button emphasis="secondary" data-ir="1">
Toggle externally
</Button>
<Menu
items={items}
aria-label="File actions"
triggerEmphasis="secondary"
data-ir="2"
>
Actions
</Menu>
<p>
Menu is <!--ir:4-->
{open ? "open" : "closed"}
<!--/ir--> · Last action: <!--ir:5-->
{lastAction}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/controlled.ts";
document
.querySelectorAll('[data-ir-root="menu/controlled"]')
.forEach((root) => hydrate([root]));
</script>A context menu opens where the pointer is, not at a button. Pass
anchorPoint (viewport coordinates, such as a contextmenu event’s
clientX and clientY) together with open, and the menu panel anchors to
that point instead of its trigger. placement is applied relative to the
point, and the panel still flips and shifts to stay inside the viewport.
Submenus keep opening beside their rows, and opening still focuses the first
row. A panel opened at a point has no arrow. Clear anchorPoint (null or
undefined) to anchor the menu to its trigger again.
<script lang="ts">
let open = $state(false);
let at = $state<{ x: number; y: number } | null>(null);
function oncontextmenu(event: MouseEvent) {
event.preventDefault();
at = { x: event.clientX, y: event.clientY };
open = true;
}
</script>
<div {oncontextmenu}>…</div>
<!-- The trigger stays the keyboard path to the same actions. -->
<Menu {items} bind:open anchorPoint={at}>
{#snippet trigger(props)}
<Button {...props}>Actions</Button>
{/snippet}
</Menu>
In Astro, pass the point to the menu’s handle:
getMenu(root)?.open({ at: { x: event.clientX, y: event.clientY } }). The
point applies to that open only, and the next open is at the trigger again.
Popover takes the same anchorPoint prop.
A closed menu renders only its trigger. The panel, with its list and submenus, mounts when the menu opens and is removed after it closes, once the exit animation has finished. Submenus mount the same way, only while they are open. A list that puts a menu on every row (a snooze menu, a label menu and a “more” menu per email) therefore only pays for the triggers.
Opening still focuses the first row (or the search field), and typeahead,
arrow keys and light dismiss work on the first open. While the menu is
closed, the trigger has no aria-controls, because the list it would name is
not in the DOM.
mountStrategy changes this:
"lazy" (the default) behaves as described above."keep" mounts the panel the first time the menu opens and then keeps it.
Use it for a menu that opens often and is expensive to build."eager" keeps the closed panel mounted, which was the behavior before
lazy mounting. Use it if your code looks up the menu’s elements while it is
closed.<Menu {items} mountStrategy="keep" />
Astro renders every level on the server and hydrates it, so an Astro menu is
always eager and has no mountStrategy prop.
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.
Last action: —
---
// GENERATED from packages/demos/menu/rich-items.demo.tsx — edit the source, not this file.
import Menu from "@pindoba/astro-menu";
import Stamp from "@pindoba/astro-stamp";
import Badge from "@pindoba/astro-badge";
import { BuildingComplex, Globe, Plus } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
const lastAction = "—";
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="menu/rich-items"
>
<Menu
items={[
{
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…",
},
]}
aria-label="Switch workspace"
triggerEmphasis="secondary"
data-ir="1"
>
<Stamp slot="acme:leading" size="md">
<BuildingComplex size={16} />
</Stamp>
<Stamp slot="globex:leading" size="md">
<Globe size={16} />
</Stamp>
<Badge slot="globex:trailing" size="sm" tone="success">
Pro
</Badge>
<Stamp slot="more:leading" size="md">
<BuildingComplex size={16} />
</Stamp>
<Stamp slot="initech:leading" size="md">
<BuildingComplex size={16} />
</Stamp>
<Stamp slot="umbrella:leading" size="md">
<Globe size={16} />
</Stamp>
<Stamp slot="create:leading" size="md">
<Plus size={16} />
</Stamp>
Switch workspace
</Menu>
<p>
Last action: <!--ir:3-->
{lastAction}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/rich-items.ts";
document
.querySelectorAll('[data-ir-root="menu/rich-items"]')
.forEach((root) => hydrate([root]));
</script>An item’s shortcut renders as a KbdGroup on the row’s trailing edge — "soft" by default (quiet square keys on a subtle fill, no border or frame), sized to the menu’s rows — and sets the item’s aria-keyshortcuts. Write mod ("mod+N", ["mod", "Shift", "P"]) so the key reads ⌘ on a Mac and Ctrl elsewhere; whitespace makes a sequence ("G I"). shortcutAppearance ("soft" | "framed" | "separate" | "plain") and shortcutPlatform ("auto" | "mac" | "other") set every row at once, and submenus inherit them. For any other KbdGroup prop, reach ListBox’s shortcut slot through passThrough.listbox ({ listbox: { shortcut: { props: { separator: "plus" } } } }).
<Menu items={items} shortcutAppearance="plain" />
The floating list is a ListBox whose container is a Panel root. Menu exposes it through four props — listboxRadius, listboxShadow, listboxBorder and listboxBackground — which map onto the list’s Panel radius / shadow / border / background.
Set the corner explicitly rather than reaching for radius="inner" here: the list is mounted inside a transparent, unpadded popover <dialog>, so inner would resolve against that dialog’s xl corner instead of anything meaningful. The defaults are listboxRadius="md", listboxBorder="muted" and listboxBackground="surface.peak", with the floating lg elevation coming from the listbox recipe.
<Menu listboxRadius="lg" listboxShadow="xl" items={items} />
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.
Last action: —
---
// GENERATED from packages/demos/menu/account.demo.tsx — edit the source, not this file.
import Menu from "@pindoba/astro-menu";
import Stamp from "@pindoba/astro-stamp";
import { ChevronDown, LogOut } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const nameClass = css({
fontWeight: "bold",
color: "colorPalette.text.bold",
});
const emailClass = css({ fontSize: "sm", color: "colorPalette.text.muted" });
const lastAction = "—";
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="menu/account"
>
<Menu
items={[
{
type: "custom",
id: "account",
"aria-label": "Signed in as Demo Cubos, demo@cubos.dev",
},
{ type: "separator", id: "s1" },
{
type: "action",
id: "sign-out",
label: "Sair",
tone: "danger",
},
]}
mountStrategy="eager"
aria-label="Account menu"
triggerEmphasis="secondary"
data-ir="1"
>
<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 size={16} />
</Stamp>
Demo Cubos <ChevronDown size={16} />
</Menu>
<p>
Last action: <!--ir:3-->
{lastAction}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/account.ts";
document
.querySelectorAll('[data-ir-root="menu/account"]')
.forEach((root) => hydrate([root]));
</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.