Skip to main content
xriptxr

Menu

Info:Overlay
30

A menu button, or a cursor-anchored context menu: a popup list of actions.

dropdown menu context menu action menu kebab overflow menu popup menu

Live demo

live · @xtyle/astro

Menu

App menu bar

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. Near the right edge it right-aligns on the cursor instead of sliding away from it.

No action chosen yet.

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.

Drop one in a Toolbar to build a classic app menu bar: a File, Edit, View row of menu buttons.
Use a kebab or gear label for a row-level or panel-level actions menu.
Listen for select and switch on detail.value to run the chosen action; the menu closes itself.
Add context and call openAt(e.clientX, e.clientY) from a contextmenu listener to make the same menu a right-click menu — the item list, the handler, and the theming carry over untouched.
One context menu can serve many targets: swap items in the contextmenu handler before calling openAt, so a row menu and a canvas menu share a single element.

Props

5 props, straight from the manifest.

PropTypeDefaultBindingsDescription
items MenuItem[]
html svelte astro
The action list. An action is { label, value?, disabled?, hint?, intent? }, where hint is a trailing muted/mono accelerator like Ctrl+S and intent: "danger" tints a destructive row (a delete / discard / close). A { separator: true } entry renders a divider, and a { heading: string } entry opens a labeled group the following actions sit under (a role="group" named by the heading). Passed as a property in the bindings (serialized to JSON for the element).
label string
html svelte astro
The trigger text, also the popup's accessible name (e.g. "File").
open boolean false
html svelte
Reflects (and controls) whether the menu is open.
context boolean false
html svelte astro
Cursor-anchored mode: the same menu with no trigger. Nothing renders until menu.openAt(x, y) opens the popup at a pointer position (from a contextmenu handler), and the host collapses to display: contents so it takes no layout wherever it is declared. Everything else — items, chrome, keyboard, select — is unchanged.
openAt(x, y, opts?) (x: number, y: number, opts?: { focus?: "first" | "last" | "none"; placement?: "top" | "bottom" | "left" | "right"; align?: "start" | "center" | "end" }) => void
html svelte astro
Method (not an attribute): opens the menu at a point in viewport coordinates — menu.openAt(event.clientX, event.clientY) inside a contextmenu handler, after preventDefault(). Defaults to dropping from the point with the first action focused, flipping above it and right-aligning on it near a viewport edge. Works on a trigger-anchored menu too; the trigger anchoring returns once the menu closes.

Events

What the component emits, and what rides along on event.detail. The name in the first column is the one addEventListener takes.

EventDetailBindingsDescription
select { value, label }
html svelte astro
A menu item was chosen.

Methods

What you can call on the element itself — the half of the surface a prop or an event cannot express. A control driven by a gesture, or one whose state the platform owns, opens here. Astro renders on the server and hands back no instance, so no method is reachable from it.

MethodReturnsBindingsDescription
openAt(x: number, y: number, opts?: { focus?: "first" | "last" }) void
html svelte
Open at a point in viewport coordinates, which is what a right-click menu wants: the anchor is where the pointer was, not an element.
openFrom(anchor: HTMLElement, opts?: { focus?: "first" | "last" }) void
html svelte
Open anchored to an element, placed and flipped against the viewport the way a trigger-owned menu is.

Appearance

States

expanded

.xtyle-menu__trigger[aria-expanded="true"]

The trigger while its menu is open. Takes the selected tint.

item-active

.xtyle-menu__item:hover, .xtyle-menu__item:focus-visible

The hovered or keyboard-focused action: the accent-tinted row.

item-danger

.xtyle-menu__item[data-intent="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

.xtyle-menu__item[aria-disabled="true"]

A locked action: muted and non-interactive, skipped by arrow navigation.

Anatomy

The named parts that make up the component. A ::part() handle is reachable from your own stylesheet; the class beside it is the component's internal selector, which a shadow boundary keeps to itself.

menu

Success:::part(menu) .xtyle-menu

The wrapper holding the trigger and its popover.

trigger

Success:::part(trigger) .xtyle-menu__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.

--space-1 --space-3 --font-sans --text-sm --fg-1 --fg-0 --bg-1 --border-thin --line --radius-md --state-hover --state-selected --ring --border-normal --border-thick --duration-fast --ease-standard

popup

Success:::part(popup) .xtyle-menu__popup

The role="menu" floating surface, rendered in the top layer via the Popover API so it escapes ancestor clipping.

--space-1 --font-sans --text-sm --surface-overlay --surface-overlay-border --border-thin --radius-md --elevation-3

item

Success:::part(item) .xtyle-menu__item

An action: a role="menuitem" button. Takes the accent tint on hover/focus.

--space-1 --space-3 --fg-1 --fg-disabled --accent-bg --fg-0 --radius-sm --duration-fast --ease-standard

item-hint

Success:::part(item-hint) .xtyle-menu__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.

--font-mono --text-xs

heading

internal .xtyle-menu__heading

A group label for a heading item: the muted, uppercase title a labeled section of actions sits under.

--space-1 --space-3 --text-xs --fg-2

separator

internal .xtyle-menu__separator

A role="separator" divider rendered for a separator item.

--border-thin --line --space-1 --space-2

Tokens & coverage

What the component consumes, checked live against what the algorithm produces.

Success:fully covered 30/30 consumed tokens produced default register: 310 tokens

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

Builds the WAI-ARIA menu button pattern: the trigger carries aria-haspopup="menu", aria-expanded, and aria-controls; the popup is a role="menu" of role="menuitem" actions named by the trigger label.
From the trigger, Enter / Space / Down open the menu and focus the first action; Up opens and focuses the last.
In the menu, Up/Down move between enabled actions (wrapping), Home/End jump to the first/last, Enter/Space activates the focused action, Tab closes the menu, and Escape closes it and returns focus to the trigger.
Disabled actions carry aria-disabled and are skipped by arrow navigation and not activatable.
A heading item opens a role="group" named by the heading text, so assistive tech announces which section an action belongs to; the visible heading is aria-hidden to avoid a double read, and headings are not focus targets, so arrow navigation walks only the actions.
A hint (the accelerator keycap) is aria-hidden, so it shows visually without padding the action's accessible name; the name stays the bare label.
A single roving focus keeps the menu a coherent keyboard surface. Clicking outside closes it (the Popover API's light-dismiss); clicking an action activates it.
A context menu opens focused on its first action, so the keyboard owns the menu the moment it appears; Escape closes it and returns focus to whatever was focused when it opened (there is no trigger to go back to). Pass focus: "none" to leave focus where the pointer left it.
A right-click surface still needs a keyboard route to the same actions — the context menu is not one on its own. Give the surface a keyboard affordance (a kebab Menu with the same items, or a Shift+F10 handler calling openAt at the focused element's rect) so the actions are reachable without a pointer.

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>

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>