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

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

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.

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>

Making the chosen row read at a glance

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:

  • Opening the menu focuses the field.
  • ↓ / ↑ 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.
  • Whenever the query changes, the highlight returns to the first result.
  • → 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>

Drill-in submenus

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).
  • Drilling in or out moves the cursor to the first row of the new level.

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

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).
  • At the edges, → on a submenu row drills in (or opens its flyout) and ← goes back a level (or closes the submenu).
  • Disabled rows are skipped.

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>

Destructive actions

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>

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

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

Opening at a point

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.

Mounting

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.

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

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

Shortcuts

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

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

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

  • Components
  • Blocks