# Menubar

> A row of menus with one tab stop: the side arrows move between items and between open menus, ArrowDown opens one.

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

The row of menus of a desktop-style application, such as File, Edit and View, joined into one widget with one tab stop and arrow keys.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the menubar and menu behaviors register themselves.
	import '@svelte-lean/styles/menu.css'; // brings popover.css
	import '@svelte-lean/styles/menubar.css';
	import type { MenuSelectDetail } from '@svelte-lean/primitives/menu';
	import { on } from 'svelte/events';

	const menus = {
		File: ['New file', 'Open…', 'Save'],
		Edit: ['Undo', 'Redo', 'Find'],
		View: ['Zoom in', 'Zoom out', 'Full screen']
	};
	let last = $state('none');

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

<ul
	role="menubar"
	aria-label="Editor"
	data-slean="menubar"
	{@attach (node) => on(node, 'slean:select', onSelect)}
>
	{#each Object.entries(menus) as [label, items], i (label)}
		<li role="none">
			<button
				type="button"
				id="{label}-item"
				role="menuitem"
				popovertarget="{label}-menu"
				tabindex={i === 0 ? 0 : -1}
			>
				{label}
			</button>
			<div
				id="{label}-menu"
				popover
				data-slean="menu"
				data-slean-align="start"
			>
				{#each items as item (item)}
					<button type="button" data-slean-value={item}>{item}</button>
				{/each}
			</div>
		</li>
	{/each}
</ul>
<p>Last command: {last}</p>
```

Tab to File, use the side arrows along the bar and ArrowDown to open; inside a menu the side arrows move to the next menu.

## Examples

### Checkbox and radio items

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

	const aligns = ['Left', 'Center', 'Right'];
	let bold = $state(false);
	let italic = $state(true);
	let align = $state('Left');

	function onFormat(event: Event) {
		const { value, checked } = (event as CustomEvent<MenuSelectDetail>).detail;
		if (value === 'bold') bold = checked === true;
		else if (value === 'italic') italic = checked === true;
		else if (value) align = value;
	}
</script>

<ul
	role="menubar"
	aria-label="Format"
	data-slean="menubar"
	{@attach (node) => on(node, 'slean:select', onFormat)}
>
	<li role="none">
		<button type="button" id="text-item" role="menuitem" popovertarget="text-menu" tabindex="0">
			Text
		</button>
		<div id="text-menu" popover data-slean="menu">
			<button type="button" role="menuitemcheckbox" aria-checked={bold} data-slean-value="bold">
				Bold <kbd>Ctrl+B</kbd>
			</button>
			<button type="button" role="menuitemcheckbox" aria-checked={italic} data-slean-value="italic">
				Italic <kbd>Ctrl+I</kbd>
			</button>
		</div>
	</li>
	<li role="none">
		<button type="button" id="align-item" role="menuitem" popovertarget="align-menu" tabindex="-1">
			Align
		</button>
		<div id="align-menu" popover data-slean="menu">
			<div role="group" aria-label="Alignment">
				{#each aligns as option (option)}
					<button
						type="button"
						role="menuitemradio"
						aria-checked={align === option}
						data-slean-value={option}
					>
						{option}
					</button>
				{/each}
			</div>
		</div>
	</li>
</ul>
<p
	style:font-weight={bold ? 600 : 400}
	style:font-style={italic ? 'italic' : 'normal'}
	style:text-align={align.toLowerCase()}
>
	The quick brown fox jumps over the lazy dog.
</p>
```

The menus keep the menu contract: checkbox and radio items, slean:select on the bar.

## Usage

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

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

`+layout.svelte or any client module`

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

`stylesheets`

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

Render a `role="menubar"` list of `role="none"` items, each holding a `role="menuitem"` button with `popovertarget` and, right after it, its menu: a `data-slean="menu"` popover of buttons. The bar is visible at load, so its roles and its one tab stop (`tabindex="0"` on the first item, `-1` on the others) are markup; each menu gets its roles and is labelled by its item when it opens.

Listen to `slean:select` on the bar for the chosen item of any menu. A bar item with `aria-disabled="true"` is skipped by the arrows and does not open on hover.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<ul role="menubar" aria-label="…" data-slean="menubar">` | – | yes | No options. |
| item | `<button role="menuitem" popovertarget="…" tabindex="0\|-1">` | – | yes | Inside \<li role="none"\>, before its menu; one has tabindex="0". |
| menu | `<div popover data-slean="menu">` | – | yes | The menu contract: role="menu" and aria-labelledby (its item) are written when it opens. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `ArrowRight`/`ArrowLeft` | focus on the bar | Next or previous item, wrapping; swapped under dir="rtl" |
| `Home`/`End` | focus on the bar | First or last item |
| `ArrowDown`/`Enter`/`Space` | focus on an item | Opens its menu; focus moves to the first menu item |
| `ArrowRight`/`ArrowLeft` | focus in a menu | Closes it and opens the next or previous menu |
| `Escape` | focus in a menu | Closes it; focus returns to its item |

## Styling

`menubar.css` lays the items out in a bar and draws the item whose menu is open with the current color's soft tint, with `:has()` on the list item; `data-color` on the bar or an item picks the color. The menus are drawn by `popover.css` and `menu.css`. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | padding |
| `--slean-border` | `var(--slean-neutral-6)` | border |
| `--slean-radius-md` | `var(--slean-radius)` | border-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-control-height-sm` | `2rem` | min-block-size |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-inline |
| `--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-font-weight-medium` | `500` | font-weight |
| `--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-color-soft` | `var(--slean-primary-soft)` | background |
| `--slean-color-soft-text` | `var(--slean-primary-soft-text)` | color |
| `--slean-control-height-md` | `2.25rem` | min-block-size |

State selectors the stylesheet targets, all from the platform or ARIA: `:focus-visible`, `:hover`, `:popover-open`, `[aria-disabled="true"]`, `[role="menuitem"]`.

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 2064 B brotli · 2326 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `role="menubar"`, `popovertarget`, `showPopover()`, `:popover-open`, `:has()`
- **Shared listeners**: keydown, focusin, pointerover
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `role="menubar" of popovertarget menu buttons`

Keydown, focusin and pointerover handlers with the shared router, plus the menu behavior’s own; a thousand bars keep one listener per type (`tests/menubar.test.ts`). The open menu is the popover matching `:popover-open` and the focusable item the one with `tabindex="0"`, so no state is kept.

Why this implementation

Each menu is already a popover opened by its item’s `popovertarget`, with light dismiss, Escape, focus return and the top layer, and the Menu primitive gives each menu its keys. What makes a row of menus a menubar in the WAI-ARIA pattern is the movement between them: one tab stop for the bar, the side arrows along it, the side arrows inside an open menu moving to the neighbouring menu, and hover switching while a menu is open.

None of that needs memory: the menus are found through their items’ `popovertarget`, and three shared listeners cover every bar on the page.

### The browser owns

- each menu: showing, light dismiss, Escape, focus return and the top layer (popovertarget)
- Enter and Space on an item open its menu (native button activation)
- the expanded state of each item, exposed from popovertarget

### Svelte Lean owns

- one tab stop for the bar; the side arrows, Home and End between its items; RTL
- ArrowDown to open a menu; the side arrows inside a menu to move to the neighbouring one
- switching the open menu when the pointer moves to another item
- the menu behavior inside each menu (the menu contract)
- menubar.css: the bar, its items and the item whose menu is open, in the current color

Accessibility

- `role="menubar"` named by `aria-label`; each item a `menuitem` whose expanded state the platform exposes from `popovertarget`; each menu labelled by its item.
- One tab stop for the bar; the side arrows, Home and End move along it, mirrored under `dir="rtl"`.
- ArrowDown, Enter or Space opens a menu and focus moves to its first item; Escape closes it and focus returns to the item.
- A menubar is for application commands. Site navigation is a `<nav>` with links.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Popover API` | Baseline 2024 (Chrome 114, Safari 17, Firefox 125) | The menus render inline; the bar still lists its items |
| `:has()` | Baseline 2023 | The item whose menu is open is not drawn pressed |

Without JavaScript

Each item opens its menu natively through popovertarget; the menus' keys and the bar's arrows need the runtime, and Tab reaches only the first item.

Server rendering and hydration

Render the bar with one item at tabindex 0 and every menu closed.

Before the behaviors load, a click opens a menu (native) but the arrow keys do nothing. The first key after hydration works on the server's markup.

Compatibility

The Popover API is Baseline 2024 and `:has()` Baseline 2023. Submenus and mnemonics are not part of the primitive.

Tests and source

- `packages/primitives/tests/menubar.test.ts` Vitest: the bar items, arrows and RTL, opening, moving between menus, hover, 1000 bars
- `apps/playground/tests/primitives/layout.spec.ts` Playwright: the splitter and the context menu in Chrome, with axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/menubar/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/menubar/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/menubar/behavior.ts` the behavior definition
- `packages/primitives/src/menubar/register.ts` the registration module
- `packages/styles/css/menubar.css` the optional stylesheet
