Menu
A menu button, or a cursor-anchored context menu: a popup list of actions.
Live demo
Menu is the app-menu shape: a labeled trigger (a File button, a kebab, a profile name) that opens a floating list of actions under it. It builds the WAI-ARIA menu button pattern: the trigger carries aria-haspopup="menu" and aria-expanded, and the popup is a role="menu" of role="menuitem" actions with a single roving focus, so the keyboard walks it like a native menu.
Like Tree, it is data-driven: an items array drives the markup. An action carries a label plus optional value, disabled, and a hint (a trailing muted/mono accelerator like Ctrl+S); a { separator: true } entry renders a role="separator" divider; and a { heading: string } entry opens a labeled role="group" the following actions sit under, so a real app menu can group its commands under "File" and "Help" headers. The popup uses the native Popover API, so it renders in the top layer and escapes any clipping or stacking context an ancestor would otherwise impose, positioned under the trigger (and flipped up when there is no room below). Choosing an action fires a select event with the item's value, label, and index and closes the menu; the engine never navigates, the consumer decides what an action does. Its chrome (the overlay surface, the elevation, the accent-tinted active row) is derived, so a menu frames its actions in the theme's own voice.
The right-click menu is the same component, not a second one. Add context and the trigger is not rendered (the host collapses to display: contents, so it takes no layout wherever it sits); call menu.openAt(x, y) from a contextmenu handler and the popup opens at the pointer instead of under a button. Same items, same chrome, same roving keyboard focus, same select event, same theming: a kebab menu becomes a right-click menu by adding one attribute and one call, without touching the item list. Near a viewport edge the popup right-aligns on the cursor rather than sliding sideways off it, the way a native OS menu does, and flips above the point when there is no room below. openAt(x, y, opts) takes an optional focus ("first" | "last" | "none"), placement, and align; Escape and a click outside close it and return focus to wherever it was when the menu opened.
When to use
How this component composes with the rest of the set.
Props
5 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
Appearance
States
expanded
The trigger while its menu is open. Takes the selected tint.
item-active
The hovered or keyboard-focused action: the accent-tinted row.
item-danger
A destructive action (intent: "danger"): the row reads in the danger ink, and its hover/focus takes the danger tint instead of the accent one, so the one irreversible item stands apart.
item-disabled
A locked action: muted and non-interactive, skipped by arrow navigation.
Anatomy
The named parts that make up the component, with their selectors.
menu
The wrapper holding the trigger and its popover.
trigger
The menu button: carries aria-haspopup, aria-expanded, and the popovertarget that opens the popup. Not rendered in context mode, where the pointer is the anchor.
popup
The role="menu" floating surface, rendered in the top layer via the Popover API so it escapes ancestor clipping.
item
An action: a role="menuitem" button. Takes the accent tint on hover/focus.
item-hint
A row's trailing accelerator hint (e.g. Ctrl+S): a muted, monospaced span at the end of the action, exposed so a consumer can restyle the keycap text.
heading
A group label for a heading item: the muted, uppercase title a labeled section of actions sits under.
separator
A role="separator" divider rendered for a separator item.
Tokens & coverage
What the component consumes, checked live against what the algorithm produces.
Live coverage check against the xtyle-default register
(derive(xtyleDefault, { anchors }) →
coverComponent(manifest, register)). Every token this component
consumes must be a key the algorithm produces.
--accent-bg
--bg-1
--border-normal
--border-thick
--border-thin
--danger-bg
--danger-text
--duration-fast
--ease-standard
--elevation-3
--fg-0
--fg-1
--fg-2
--fg-disabled
--font-mono
--font-sans
--leading-normal
--line
--radius-md
--radius-sm
--ring
--space-1
--space-2
--space-3
--state-hover
--state-selected
--surface-overlay
--surface-overlay-border
--text-sm
--text-xs
Accessibility
Code
A File menu
A menu button opening a list with a disabled action and separators between groups.
<xtyle-menu label="File"></xtyle-menu>
<script>
const menu = document.querySelector("xtyle-menu");
menu.items = [
{ heading: "File" },
{ label: "New", value: "new", hint: "Ctrl+N" },
{ label: "Open…", value: "open", hint: "Ctrl+O" },
{ label: "Save", value: "save", hint: "Ctrl+S" },
{ label: "Save As…", value: "save-as", disabled: true },
{ heading: "Help" },
{ label: "Shortcuts", value: "shortcuts", hint: "?" },
{ separator: true },
{ label: "Close", value: "close", intent: "danger" },
];
menu.addEventListener("select", (e) => console.log(e.detail));
</script>
<script lang="ts">
import { Menu } from "@xtyle/svelte";
const items = [
{ heading: "File" },
{ label: "New", value: "new", hint: "Ctrl+N" },
{ label: "Open…", value: "open", hint: "Ctrl+O" },
{ label: "Save", value: "save", hint: "Ctrl+S" },
{ label: "Save As…", value: "save-as", disabled: true },
{ heading: "Help" },
{ label: "Shortcuts", value: "shortcuts", hint: "?" },
{ separator: true },
{ label: "Close", value: "close", intent: "danger" },
];
</script>
<Menu label="File" {items} onselect={(e) => console.log(e.detail)} />
---
import { Menu } from "@xtyle/astro";
const items = [
{ heading: "File" },
{ label: "New", value: "new", hint: "Ctrl+N" },
{ label: "Open…", value: "open", hint: "Ctrl+O" },
{ label: "Save", value: "save", hint: "Ctrl+S" },
{ label: "Save As…", value: "save-as", disabled: true },
{ heading: "Help" },
{ label: "Shortcuts", value: "shortcuts", hint: "?" },
{ separator: true },
{ label: "Close", value: "close", intent: "danger" },
];
---
<Menu label="File" items={items} />
A right-click menu
The same Menu with context: no trigger, opened at the pointer with openAt(x, y). Same items, same keyboard, same select event.
<div id="canvas">Right-click anywhere in here.</div>
<!-- the same Menu, minus the trigger: `context` hides it and collapses the host to `display: contents` -->
<xtyle-menu context></xtyle-menu>
<script>
const menu = document.querySelector("xtyle-menu");
menu.items = [
{ label: "Cut", value: "cut", hint: "Ctrl+X" },
{ label: "Copy", value: "copy", hint: "Ctrl+C" },
{ label: "Paste", value: "paste", hint: "Ctrl+V", disabled: true },
{ separator: true },
{ label: "Delete", value: "delete", intent: "danger" },
];
document.querySelector("#canvas").addEventListener("contextmenu", (e) => {
e.preventDefault();
menu.openAt(e.clientX, e.clientY);
});
menu.addEventListener("select", (e) => console.log(e.detail.value));
</script>
<script lang="ts">
import { Menu } from "@xtyle/svelte";
const items = [
{ label: "Cut", value: "cut", hint: "Ctrl+X" },
{ label: "Copy", value: "copy", hint: "Ctrl+C" },
{ label: "Paste", value: "paste", hint: "Ctrl+V", disabled: true },
{ separator: true },
{ label: "Delete", value: "delete", intent: "danger" },
];
let menu: HTMLElement & { openAt(x: number, y: number): void };
function onContextMenu(event: MouseEvent) {
event.preventDefault();
menu.openAt(event.clientX, event.clientY);
}
</script>
<div oncontextmenu={onContextMenu}>Right-click anywhere in here.</div>
<Menu context bind:this={menu} {items} onselect={(e) => console.log(e.detail.value)} />
---
import { Menu } from "@xtyle/astro";
const items = [
{ label: "Cut", value: "cut", hint: "Ctrl+X" },
{ label: "Copy", value: "copy", hint: "Ctrl+C" },
{ label: "Paste", value: "paste", hint: "Ctrl+V", disabled: true },
{ separator: true },
{ label: "Delete", value: "delete", intent: "danger" },
];
---
<div id="canvas">Right-click anywhere in here.</div>
<Menu context items={items} id="canvas-menu" />
<script>
const menu = document.querySelector("#canvas-menu");
document.querySelector("#canvas").addEventListener("contextmenu", (event) => {
event.preventDefault();
menu.openAt(event.clientX, event.clientY);
});
</script>