component

Menu

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).

Default

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: —

---
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>

Checkbox and radio items

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.

Word wrap: on · Minimap: off · Sort: name

---
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.

Last action: —

---
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>

Destructive actions

Set feedback: "danger" on an action item to style it as destructive — useful for irreversible operations like deleting a resource.

Last action: —

---
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>

Controlled open state

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: —

---
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>

Rich items

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: —

---
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>

Custom content

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: —

---
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.

props · 9 shown · 9 total
aria-label
string

Accessible label for the menu surface when there is no visible labelling.

id svelte
string

Stable id root (auto-generated when omitted).

items
MenuNode[]

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.

onOpenChange svelte
(open: boolean) => void

Fired when the open state changes.

open
boolean
default false

Whether the menu is open. Bindable in Svelte.

passThrough
MenuPassThrough

Per-slot style / attribute escape hatch.

placement
"top""right""bottom""left""top-start""top-end""right-start""right-end""bottom-start""bottom-end""left-start""left-end"
default "bottom-start"

Preferred placement of the menu panel relative to its trigger.

size
"sm""md""lg"
default "md"

Visual size.

trigger required slot svelte
Snippet<[MenuTriggerProps]>

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>`).

Type

  • Components
  • Blocks