# Menu

> A popover menu opened by a button or a right click (data-slean-contextmenu), with its roles written on opening, delegated keyboard navigation, typeahead and checkable items.

Page: https://svelte-lean.vercel.app/primitives/menu · Markdown: https://svelte-lean.vercel.app/primitives/menu.md · Index: https://svelte-lean.vercel.app/llms.txt

A list of actions or settings in a popover, opened by a button or a right click, with desktop-style keys.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the behavior registers itself for data-slean="menu".
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/menu.css'; // brings popover.css
	import type { MenuSelectDetail } from '@svelte-lean/primitives/menu';
	import { on } from 'svelte/events';

	let last = $state('nothing yet');

	function onAction(event: Event) {
		last = (event as CustomEvent<MenuSelectDetail>).detail.value ?? '';
	}
</script>

<button type="button" data-slean="button" data-variant="outlined" popovertarget="actions-menu" aria-haspopup="menu">
	Actions
</button>
<div
	id="actions-menu"
	popover
	data-slean="menu"
	data-slean-align="start"
	{@attach (node) => on(node, 'slean:select', onAction)}
>
	<button type="button" data-slean-value="edit">Edit <kbd>E</kbd></button>
	<button type="button" data-slean-value="duplicate">Duplicate <kbd>Ctrl+D</kbd></button>
	<button type="button" data-slean-value="archive">Archive</button>
	<hr />
	<button type="button" data-slean-value="delete" data-color="error">Delete</button>
</div>
<p>Selected: {last}</p>
```

Open it, then ArrowDown, Home, End or a letter; Enter selects and Escape returns focus to the button.

## Examples

### Checkbox and radio items

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/menu.css';
	import type { MenuSelectDetail } from '@svelte-lean/primitives/menu';
	import { on } from 'svelte/events';

	const themes = ['Light', 'Dark', 'System'];
	let grid = $state(true);
	let rulers = $state(false);
	let theme = $state('System');

	function onView(event: Event) {
		const { value, checked } = (event as CustomEvent<MenuSelectDetail>).detail;
		if (value === 'grid') grid = checked === true;
		else if (value === 'rulers') rulers = checked === true;
		else if (value) theme = value;
		// Keep the menu open while the checkboxes are toggled.
		if (value === 'grid' || value === 'rulers') event.preventDefault();
	}
</script>

<button type="button" data-slean="button" data-variant="outlined" popovertarget="view-menu" aria-haspopup="menu">
	View
</button>
<div
	id="view-menu"
	popover
	data-slean="menu"
	data-slean-align="start"
	{@attach (node) => on(node, 'slean:select', onView)}
>
	<!-- Svelte flags aria-checked on a plain button, so the checkbox role is written here. -->
	<button type="button" role="menuitemcheckbox" aria-checked={grid} data-slean-value="grid">
		Show grid
	</button>
	<button type="button" role="menuitemcheckbox" aria-checked={rulers} data-slean-value="rulers">
		Show rulers
	</button>
	<hr />
	<div role="group" aria-labelledby="theme-label">
		<span id="theme-label" data-slean-part="group-label">Theme</span>
		{#each themes as option (option)}
			<button type="button" role="menuitemradio" aria-checked={theme === option} data-slean-value={option}>
				{option}
			</button>
		{/each}
	</div>
</div>
<p>Grid {grid ? 'on' : 'off'} · rulers {rulers ? 'on' : 'off'} · {theme} theme</p>
```

The behavior writes aria-checked; preventDefault() on slean:select keeps the menu open for the checkboxes.

### Row actions

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/menu.css';
	import type { MenuSelectDetail } from '@svelte-lean/primitives/menu';
	import { on } from 'svelte/events';

	const files = [
		{ id: 'report', name: 'Q3 report.pdf' },
		{ id: 'roadmap', name: 'Roadmap.key' },
		{ id: 'invoice', name: 'Invoice 1042.pdf' }
	];
	let notice = $state('');

	const onRow = (name: string) => (event: Event) => {
		notice = `${(event as CustomEvent<MenuSelectDetail>).detail.value}: ${name}`;
	};
</script>

<ul class="files">
	{#each files as file (file.id)}
		<li>
			{file.name}
			<!-- The menu is labelled by its button, so the button's aria-label names it. -->
			<button
				type="button"
				data-slean="button"
				data-variant="text"
				data-color="neutral"
				data-size="small"
				data-icon-only
				aria-label="Actions for {file.name}"
				popovertarget="{file.id}-menu"
				aria-haspopup="menu"
			>
				<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
					<circle cx="5" cy="12" r="1.75" />
					<circle cx="12" cy="12" r="1.75" />
					<circle cx="19" cy="12" r="1.75" />
				</svg>
			</button>
			<div
				id="{file.id}-menu"
				popover
				data-slean="menu"
				data-slean-align="end"
				{@attach (node) => on(node, 'slean:select', onRow(file.name))}
			>
				<button type="button" data-slean-value="Rename">Rename</button>
				<button type="button" data-slean-value="Download">Download</button>
				<hr />
				<button type="button" data-slean-value="Delete" data-color="error">Delete</button>
			</div>
		</li>
	{/each}
</ul>
<p role="status">{notice}</p>

<style>
	.files {
		display: grid;
		gap: 0.25rem;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.files li {
		display: flex;
		align-items: center;
		justify-content: space-between;
	}
</style>
```

An icon-only button per row; the menu is labelled by the button, so its aria-label names the menu.

### Context menu

```svelte
<script lang="ts">
	// data-slean-contextmenu brings @svelte-lean/primitives/menu/contextmenu/register.
	import '@svelte-lean/styles/menu.css';
	import type { ContextmenuOpenDetail, MenuSelectDetail } from '@svelte-lean/primitives/menu';
	import { on } from 'svelte/events';

	const files = ['report.pdf', 'budget.xlsx', 'roadmap.md', 'logo.svg'];
	let file = $state('');
	let last = $state('');

	// slean:open names the element the menu was requested on.
	function opening(event: Event) {
		const { target } = (event as CustomEvent<ContextmenuOpenDetail>).detail;
		file = target.closest('li')?.textContent?.trim() ?? '';
	}

	function chosen(event: Event) {
		last = `${(event as CustomEvent<MenuSelectDetail>).detail.value} ${file || 'the folder'}`;
	}
</script>

<!-- One menu for the whole list: the target names it. -->
<ul
	class="files"
	tabindex="0"
	aria-label="Files"
	data-slean-contextmenu="file-actions"
	{@attach (node) => on(node, 'slean:open', opening)}
>
	{#each files as name (name)}
		<li tabindex="-1">{name}</li>
	{/each}
</ul>
<div
	id="file-actions"
	popover
	aria-label="File actions"
	data-slean="menu"
	{@attach (node) => on(node, 'slean:select', chosen)}
>
	<button type="button" data-slean-value="open">Open</button>
	<button type="button" data-slean-value="rename">Rename <kbd>F2</kbd></button>
	<button type="button" data-slean-value="duplicate">Duplicate</button>
	<hr />
	<button type="button" data-slean-value="delete" data-color="error">Delete <kbd>Del</kbd></button>
</div>
<p>{last ? `Chose: ${last}` : 'Right-click a file, or focus the list and press Shift+F10.'}</p>

<style>
	.files {
		display: grid;
		gap: 0.125rem;
		margin: 0;
		padding: 0.25rem;
		border: 1px dashed var(--slean-border);
		border-radius: var(--slean-radius-md);
		list-style: none;
		cursor: context-menu;
	}

	.files li {
		padding: 0.375rem 0.5rem;
		border-radius: var(--slean-radius-sm);
	}
</style>
```

data-slean-contextmenu names the menu: a right click opens it at the pointer, Shift+F10 under the focused file; slean:open tells which file asked.

### Links, colors and disabled items

```svelte
<script lang="ts">
	import '@svelte-lean/styles/avatar.css';
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/menu.css';
</script>

<button
	type="button"
	data-slean="button"
	data-variant="text"
	data-color="neutral"
	popovertarget="account-menu"
	aria-haspopup="menu"
>
	<span data-slean="avatar" data-size="small">
		<span data-slean-part="fallback" aria-hidden="true">AO</span>
	</span>
	Ada Okafor
</button>
<div id="account-menu" popover data-slean="menu" data-slean-align="start">
	<span data-slean-part="group-label">ada@example.com</span>
	<!-- Link items navigate; Space activates them too. -->
	<a href="/docs">Documentation</a>
	<a href="/primitives">Primitives</a>
	<!-- aria-disabled stays focusable but is skipped by the arrows; disabled is not focusable. -->
	<button type="button" aria-disabled="true">Billing</button>
	<button type="button" disabled>Transfer ownership</button>
	<hr />
	<button type="button" data-color="primary">Upgrade plan</button>
	<button type="button">Sign out</button>
</div>
```

Billing is focusable but skipped by the arrows; Transfer ownership is not focusable; data-color colors an item.

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="menu"`; without it, import the register module once.

```bash
npm install @svelte-lean/primitives
```

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/menu/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/popover.css';
import '@svelte-lean/styles/menu.css';
```

Give the button `popovertarget` and `aria-haspopup="menu"`, and the menu `popover`, an `id` and `data-slean="menu"`. Items are `<button type="button">` and `<a href>`; `<hr>` separates; a `<kbd>` is a shortcut; `data-color` colors an item. When the menu opens, the behavior writes `role="menu"`, `aria-labelledby` naming the button, and `menuitem` (or `menuitemcheckbox` with `aria-checked`) on items without a role; write `menuitemradio` yourself, inside a `role="group"`. Listen to `slean:select` on the menu: `detail.value` is the item’s `data-slean-value`, `detail.checked` the new state of a checkbox or radio item.

For a context menu, put `data-slean-contextmenu="<menu id>"` on the element that has it, make that element focusable, and name the menu with `aria-label`. `slean:open` on the element says which part was asked. A menu is for actions and settings; site navigation is a `<nav>` with links. `data-slean-autofocus="none"` keeps focus on the button when the menu opens.

`data-slean-autofocus`

```html
<!-- Keep focus on the invoker when the popover opens: -->
<div id="file-menu" popover data-slean="menu" data-slean-autofocus="none">
```

`the context menu without @svelte-lean/vite`

```ts
// Without @svelte-lean/vite, import the context-menu trigger once, next to the menu:
import '@svelte-lean/primitives/menu/register';
import '@svelte-lean/primitives/menu/contextmenu/register';
```

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<div id="…" popover data-slean="menu">` | – | yes | popover (auto) gives the top layer, light dismiss and Escape. role="menu" and aria-labelledby are written when it opens. Options: data-slean-autofocus="first" (default) or "none"; data-slean-side and data-slean-align place it. |
| invoker | `<button type="button" popovertarget="<menu id>" aria-haspopup="menu">` | – | no | Names the menu: the behavior points the menu’s aria-labelledby at it (giving it the id \<menu id\>-trigger when it has none). |
| context target | `any focusable element with data-slean-contextmenu="<menu id>"` | – | no | Opens the menu at the pointer or under the focused element (the menu/contextmenu module); name the menu with aria-label. |
| item | `<button type="button"> or <a href>` | – | yes | Gets role="menuitem", or "menuitemcheckbox" with aria-checked, when the menu opens. data-color colors it; a \<kbd\> inside is its shortcut. |
| group | `<div role="group"> with menuitemradio items` | – | no | Scopes exclusive radio items; named by aria-label or its group-label. |
| separator | `<hr>` | – | no | A rule between items. |
| group-label | `<span>` | `group-label` | styles only | A group heading. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Enter`/`Space` | focus on the invoker | Toggles the popover (native); the first enabled item receives focus |
| `ArrowDown`/`ArrowUp` | focus inside the menu | Next or previous enabled item, wrapping |
| `Home`/`End` | focus inside the menu | First or last enabled item |
| `a character` | focus inside the menu | Typeahead over item text; a repeated character cycles; the buffer resets after the typeahead timeout |
| `Enter` | focus on an item | Native activation of the button or link; its click selects |
| `Space` | focus on an item | Clicks the item without scrolling the page, so link items activate too |
| `Escape` | focus inside the menu | Hides the popover and focuses the invoker |
| `Tab`/`Shift+Tab` | focus inside the menu | Hides the popover; sequential navigation continues from the invoker |
| `ArrowLeft`/`ArrowRight` | focus inside the menu | Not handled (submenus are not implemented) |
| `Shift+F10` | focus on a data-slean-contextmenu target | Opens its menu under the focused element (the context-menu key does the same) |

## Styling

`menu.css` styles the rows by element (the menu's buttons and links) and by role, the separator, the group label, the `<kbd>` shortcut, the check mark on `[aria-checked="true"]`, `data-color` rows drawn from the current color with its soft tint while active, and an inset focus ring for `:focus-visible`. The surface, placement and open transition come from `popover.css`, which `menu.css` imports. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | padding, padding-block, margin-block, margin-inline |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap, padding-inline, padding-inline-start, inset-inline-start |
| `--slean-control-height-sm` | `2rem` | min-block-size |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-muted` | `var(--slean-neutral-3)` | background |
| `--slean-focus-ring` | `var(--slean-focus-ring-width) solid var(--slean-focus-ring-color)` | outline |
| `--slean-focus-ring-width` | `2px` | outline-offset |
| `--slean-icon-check` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-border` | `var(--slean-neutral-6)` | background |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-text-xs` | `0.75rem` | font-size |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | padding-inline-start |
| `--slean-color-main` | `var(--slean-primary-main)` | color |
| `--slean-color-soft` | `var(--slean-primary-soft)` | background |
| `--slean-color-soft-text` | `var(--slean-primary-soft-text)` | color |

State selectors the stylesheet targets, all from the platform or ARIA: `:disabled`, `:focus-visible`, `:hover`, `[aria-checked="true"]`, `[aria-checked]`, `[aria-disabled="true"]`, `[href]`, `[role="group"]`, `[role="menuitem"]`, `[role="menuitemcheckbox"]`, `[role="menuitemradio"]`, `[role="separator"]`.

Variant attributes: `data-color`.

## Controlled integration

`slean:select` bubbles from the root after the checked state is written and before the popover hides; `preventDefault()` keeps the menu open. For link items, cancel the click event to stop the navigation. `closeMenu()`, `menuItems()`, `menuInvoker()` and `completeMenu()` from `@svelte-lean/primitives/menu` are the helpers; `openContextmenu(menu, x, y)` opens a menu at a point.

`file-menu.svelte`

```svelte
<script lang="ts">
	import { closeMenu } from '@svelte-lean/primitives/menu';

	let root: HTMLElement;
</script>

<div id="file-menu" popover data-slean="menu" bind:this={root}>
	…
</div>
<button type="button" onclick={() => closeMenu(root)}>Close the menu from outside</button>
```

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 2773 B brotli · 3107 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `popover`, `beforetoggle`, `role="menu"`, `role="menuitem"`, `aria-checked`, `contextmenu event`
- **Shared listeners**: click, keydown, beforetoggle, toggle
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `[popover] + ARIA menu (role=menu, menuitem)`

Four listeners (`click`, `keydown`, and `beforetoggle` and `toggle` in the capture phase) serve every menu on the page, and one typeahead buffer is created on first use. The roles are completed once per opening, with one query of the menu's buttons and links. A context menu adds one `contextmenu` and one `pointerup` listener for every target on the page, and one layout read per opening. There is no per-root state and no observer. The kernel is shared: a page with tabs and menu builds to one Svelte Lean chunk (the `tabs-and-menu` fixture).

Why this implementation

A menu button is two things. Visibility, light dismiss, Escape, the top layer, focus return and `aria-expanded` are the Popover API’s job, and the browser does them. Desktop-style keys inside the menu are not: arrows between items, Home and End, typeahead, Space on a link item, closing on selection, and the checked state of checkbox and radio items.

Svelte Lean adds only that second part, as one behavior shared by every menu on the page, and calls `hidePopover()` to close. A closed menu is not in the accessibility tree, so the behavior also writes the roles right before it opens (ADR 0011 §2.2): the markup is buttons and links. A context menu is the same menu opened from another element, so it is an attribute of that element, not a second component.

### The browser owns

- showing and hiding the popover, the top layer and light dismiss
- aria-expanded on the invoker and focus return for an auto popover
- Enter on a focused \<button\> or \<a\> item (native activation)
- placement, through CSS anchor positioning in the stylesheet
- the contextmenu event for a right click, the context-menu key and Shift+F10

### Svelte Lean owns

- role="menu", its name and the item roles, written right before the menu opens
- focus on open, ArrowUp and ArrowDown, Home and End, typeahead, Space, Escape and Tab
- aria-checked of checkbox and radio items and the cancelable slean:select event
- the data-slean-contextmenu trigger: the menu at the pointer, inside the viewport, and slean:open
- closeMenu(), menuItems(), menuInvoker(), completeMenu() and openContextmenu()
- development validation of the markup
- menu.css and the contract

Accessibility

- Root: `popover`, an `id` and `data-slean="menu"`; the behavior writes `role="menu"` and `aria-labelledby` (the button) right before it opens, unless they are authored. Button: `aria-haspopup="menu"`; `aria-expanded` comes from `popovertarget`. A context menu has no button: name it with `aria-label`.
- Items are `<button type="button">` or `<a href>`; their `menuitem` or `menuitemcheckbox` role is completed on opening, `menuitemradio` is authored, grouped by the nearest `role="group"`. Svelte flags `aria-checked` on a plain button, so write `role="menuitemcheckbox"` in a Svelte template. `<hr>` is a separator.
- On open the first enabled item receives focus (or nothing with `data-slean-autofocus="none"`). On close (Escape, selection, Tab) the platform returns focus to the button for an auto popover; the behavior also focuses the button it finds through `aria-labelledby`, which covers `popover="manual"`. A context menu returns focus to the element that had it.
- An item with `disabled` is not focusable; with `aria-disabled="true"` it is focusable, skipped by arrows, Home, End and typeahead, and its click is prevented. `aria-disabled="true"` on the root skips routing.
- Editable elements inside the root keep their keys; only Escape and Tab still close the menu. Vertical only: nothing to flip under RTL; a context menu opens to the left of the pointer in a right-to-left page.
- Development validation warns about a root without `popover` or `id`, a menu no button opens and no `aria-label` names, items with a menu role that are not `<button>` or `<a>`, checkbox or radio items without `aria-checked`, and a context-menu target that is not focusable.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Popover API: popover, popovertarget, :popover-open, ToggleEvent, hidePopover()` | Newly available since April 2024 (Chrome 114, Safari 17, Firefox 125) | The attribute is ignored and the menu renders inline |
| `CSS anchor positioning: position-area` | Newly available since January 2026 (Chrome 125, Safari 26, Firefox 147) | The menu keeps the platform default: centered in the top layer |
| `The contextmenu event (for data-slean-contextmenu)` | Widely available; not dispatched for a long press on iOS | The browser’s own menu appears; offer the same actions from a button |
| `ARIA menu roles (menu, menuitem, menuitemcheckbox, menuitemradio, group, separator)` | ARIA 1.2, widely supported by assistive technology | Not applicable within the support policy |

Without JavaScript

With the Popover API the menu opens and closes and link items work; button items do nothing, movement inside is Tab-based and no role is written, so the popover is plain buttons and links. A right click shows the browser's own menu. Without Popover API support the attribute is ignored and the content renders inline.

Server rendering and hydration

Static HTML; the menu is closed on load, so the roles it writes on opening are never missing from what assistive technology reads. The initial `aria-checked` values are authored. The register modules are safe to import on the server: without a `document` the registration waits until a browser runtime exists (`packages/primitives/tests/ssr.test.ts`).

The popover opens before any script because the browser owns visibility; the roles, keyboard movement and the checked state need the register module. Hydration attaches nothing to a menu root: the listeners are on the document, and the first open is the first work done for a root.

Compatibility

The Popover API is Baseline 2024 and CSS anchor positioning Baseline since January 2026; without it the menu keeps the platform’s centered placement, and without the Popover API it renders inline. The `contextmenu` event is not dispatched for a long press on iOS: offer the same actions from a button. The behavior needs ES2022 output and `composedPath()`. Submenus and ArrowDown on the button are not implemented. The playground suite runs in Chromium and Firefox.

Tests and source

- `packages/primitives/tests/menu.test.ts` unit tests (happy-dom with a Popover API shim): the generated roles, opening, keyboard, typeahead, selection, checked state, helpers, validation
- `packages/primitives/tests/contextmenu.test.ts` the data-slean-contextmenu trigger: the pointer, the viewport, the keyboard, RTL, the release of the button, nesting, validation
- `apps/playground/tests/primitives/menu.spec.ts` Playwright: the real popover, the generated roles, colored items, top layer, light dismiss, focus restoration, typeahead timing
- `apps/playground/tests/primitives/layout.spec.ts` Playwright in Chromium and Firefox: the context menu at the pointer, the keyboard request, the same menu anchored to its button afterwards, the menubar, axe
- `apps/playground/tests/primitives/nested.spec.ts` a menu inside a tab panel and tabs inside a menu
- `fixtures/tabs-and-menu` consumer build: both registrations ship with one kernel
- `apps/playground/tests/primitives/a11y.spec.ts` axe on every playground page and on open dialog, popover and menu states

- `packages/primitives/src/menu/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/menu/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/menu/behavior.ts` the behavior definition, completeMenu(), menuItems(), menuInvoker(), closeMenu()
- `packages/primitives/src/menu/contextmenu/contract.md` the context-menu trigger: data-slean-contextmenu, slean:open, press and release
- `packages/primitives/src/menu/register.ts` the registration module, the only side effect
- `packages/primitives/src/menu/validate.ts` development validation messages
- `packages/styles/css/menu.css` the optional stylesheet
