Primitives Overlays
Menu
A menu button and a popover menu. The popover owns visibility, light dismiss and the top layer; the behavior adds focus on open, arrow-key movement, Home and End, typeahead, activation and the checked state of items through shared click, keydown and toggle listeners. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 1932 B brotli · 2162 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
popover,role="menu",role="menuitem",aria-checked,ToggleEvent- Shared listeners
- click, keydown, toggle
- Per-instance listeners
- none
- Lazy state
- none
- Native base
[popover] + ARIA menu (role=menu, menuitem)
On this page
Example
Last slean:select on the example: No selection yet.
Why this implementation exists
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
keyboard behavior inside the menu is not: arrow keys 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. This is the composition ADR 0001 aims at: native visibility
plus a small keyboard behavior, instead of JavaScript visibility, dismiss, portal, focus, positioning
and keyboard.
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
Svelte Lean owns
- 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
- closeMenu(), menuItems() and menuInvoker()
- development validation of the markup
- menu.css and the contract
Usage
With the Vite plugin, every static data-slean="menu" gets import '@svelte-lean/primitives/menu/register' appended to its compiled module;
without the plugin, write that import yourself once. menu.css imports popover.css itself, because a menu is a popover: the surface, the placement and the open
transition live there.
// Any module that runs once on the client, for example the root layout. Safe on the server,
// idempotent, and the same module the plugin injects.
import '@svelte-lean/primitives/menu/register';import '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
import '@svelte-lean/styles/menu.css'; /* imports popover.css: the surface and placement live there */Use a menu for a list of actions or settings attached to a button, with desktop-style keyboard
behavior: file menus, row actions, view options. Site navigation is a <nav> with links, not a menu. The one option keeps focus on the invoker when the popover opens:
<!-- Keep focus on the invoker when the popover opens: -->
<div id="file-menu" popover role="menu" aria-labelledby="file-button" data-slean="menu" data-slean-autofocus="none">Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <div id="…" popover role="menu" aria-labelledby="<invoker id>" data-slean="menu"> | – | yes | popover (auto) gives the top layer, light dismiss and Escape. Option: data-slean-autofocus="first" (default) or "none". |
| invoker | <button type="button" id="…" popovertarget="<menu id>" aria-haspopup="menu"> | – | yes | aria-labelledby on the menu points at it, which names the menu and lets the behavior find it. |
| item | <button type="button"> or <a href> with role="menuitem", "menuitemcheckbox" or "menuitemradio" | item | no | Items are found by role; data-slean-part="item" is a styling hook only. Checkbox and radio items carry aria-checked. |
| group | <div role="group" aria-label="…"> | – | no | Scopes exclusive radio items. |
| separator | <div role="separator"> or <hr role="separator"> | – | no | A rule between items. |
| label | <span> | label | styles only | A group heading. |
| shortcut | <span> | shortcut | styles only | A trailing hint inside an item; it does nothing by itself. |
| submenu-trigger | <button> | submenu-trigger | styles only | Draws a chevron. Submenus are not implemented. |
Runtime profile
The bytes in the runtime block are the production build of @svelte-lean/primitives/menu/register including the shared kernel, from packages/primitives/artifacts/size.json (1932 B brotli · 2162 B gzip). The kernel is shared:
a page with tabs and menu builds to one Svelte Lean chunk of 2351 B brotli in the tabs-and-menu fixture, against 1521 B brotli for tabs alone. The three listeners
(click, keydown and toggle in the capture phase) serve
every menu on the page; one typeahead buffer exists for the whole page and is created on first
use. There is no per-root state, no observer and no layout read; the proof page shows the listener account of this site, where tabs and menu
share the click and keydown entries.
Accessibility contract
- Root:
popover,role="menu", anidandaria-labelledbypointing at the invoker, which names the menu and lets the behavior find the invoker. Invoker:aria-haspopup="menu";aria-expandedcomes frompopovertarget. - Items are
<button type="button">or<a href>withrole="menuitem",menuitemcheckboxormenuitemradio; checkbox and radio items carryaria-checked, radio items are grouped by the nearestrole="group". Separators arerole="separator". - Focus: 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 invoker for an auto popover; the behavior also focuses the invoker it finds througharia-labelledbywhen focus is not there, which coverspopover="manual". - Disabled:
data-slean-disabledoraria-disabled="true"on the root skips routing. An item withdisabledis not focusable; witharia-disabled="true"it is focusable, skipped by arrows, Home, End and typeahead, and its click is prevented. - Editable elements inside the root keep their keys; only Escape and Tab still close the menu. Vertical only; no horizontal keys, nothing to flip under RTL.
- Development validation warns about: a root without
popover,role="menu"orid; items that are not<button>or<a>; checkbox or radio items withoutaria-checked;data-slean-part="item"without a menuitem role.
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 (reserved for submenus) |
Keys with Alt, Ctrl or Meta are ignored, except Escape and Tab. On the invoker, Enter and Space
toggle the popover natively; ArrowDown on the invoker is not handled because the invoker is
outside the root. The typeahead timeout is the default of createTypeahead() in @svelte-lean/core.
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 | Chrome 125 and Safari 26; not Baseline at the time of writing | The menu keeps the platform default: centered in the top layer |
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, link items work, button items do nothing and movement inside is Tab-based. Without Popover API support the attribute is ignored and the content renders inline. Nothing is hidden by this: an unregistered menu is a popover with buttons in it.
Server rendering
Static HTML; the menu is closed on load and the initial aria-checked values are
authored. The register module is safe to import on the server: without a document the registration waits until a browser runtime exists (packages/primitives/tests/ssr.test.ts).
Before hydration
The popover opens before any script because the browser owns visibility; keyboard movement and
the checked state need the register module, which evaluates before the component code once the
scripts arrive. Hydration attaches nothing to a menu root: the toggle listener is on
the document, installed when the behavior registered, and the first open is the first work done for
a root.
Styling
menu.css styles the rows by role, the separator, the group label and the shortcut
hint, the checked mark on [aria-checked="true"], an inset focus ring for :focus-visible, and the submenu chevron (styling only; submenus are not
implemented). The surface, placement and open transition come from popover.css. The
tokens it reads and the states it targets:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-1 | 0.25rem | padding, padding-block, margin-block, margin-inline |
--slean-space-2 | 0.5rem | gap, padding-inline, padding-inline-start, inset-inline-start |
--slean-control-height-sm | 2rem | min-block-size |
--slean-radius-sm | 0.375rem | 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 | 1rem | padding-inline-start |
--slean-icon-chevron-right | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 4l4 4-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask |
--slean-danger | oklch(55% 0.2 25) | color |
--slean-danger-soft | oklch(95.5% 0.03 25) | background |
--slean-danger-soft-fg | oklch(45% 0.18 25) | color |
State selectors the stylesheet targets, all from the platform or ARIA: :dir(rtl), :disabled, :focus-visible, :hover, [aria-checked="true"], [aria-disabled="true"], [aria-expanded="true"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="separator"].
Variant attributes: data-variant (danger).
Controlled integration
What exists today: the root dispatches a bubbling, cancelable slean:select CustomEvent after the checked state is updated and before the popover is hidden,
with detail of type MenuSelectDetail (item, value, checked); preventDefault() keeps the menu open.
For <a> items the navigation follows the click as usual; cancel the click event
to stop it. closeMenu(), menuItems() and menuInvoker() from @svelte-lean/primitives/menu are the helpers. An application listens with addEventListener, as the status line under the example does.
<script lang="ts">
import { closeMenu, type MenuSelectDetail } from '@svelte-lean/primitives/menu';
let root: HTMLElement;
let wrap = $state(false);
$effect(() => {
// slean:select bubbles from the root after aria-checked is updated and before the
// popover is hidden; preventDefault() keeps the menu open.
const onSelect = (event: Event) => {
const { value, checked } = (event as CustomEvent<MenuSelectDetail>).detail;
if (value === 'wrap') {
wrap = checked === true;
event.preventDefault();
}
};
root.addEventListener('slean:select', onSelect);
return () => root.removeEventListener('slean:select', onSelect);
});
</script>
<div id="file-menu" popover role="menu" aria-labelledby="file-button" data-slean="menu" bind:this={root}>
…
</div>
<button type="button" onclick={() => closeMenu(root)}>Close the menu from outside</button>Note A Svelte adapter is not built. Submenus, menubars, context menus (right click) and ArrowDown on the invoker are not implemented; each would be documented in the contract before it is built.
Compatibility notes
The menu depends on the Popover API (Baseline newly available since April 2024) for visibility
and on CSS anchor positioning (Chrome 125, Safari 26; not Baseline) for placement, with the same
fallbacks as Popover: inline content without the API, centered
placement without anchor positioning. The behavior needs ES2022 output and composedPath(). Tested in Chromium; Firefox and WebKit runs do not exist yet. The
policy is on Browser support.
Testing
packages/primitives/tests/menu.test.tsunit tests (happy-dom with a Popover API shim): opening, keyboard, typeahead, selection, checked state, helpers, validationapps/playground/tests/primitives/menu.spec.tsPlaywright: the real popover, top layer, light dismiss, focus restoration, typeahead timingapps/playground/tests/primitives/nested.spec.tsa menu inside a tab panel and tabs inside a menufixtures/tabs-and-menuconsumer build: both registrations ship with one kernelapps/playground/tests/primitives/a11y.spec.tsaxe on every playground page and on open dialog, popover and menu states
Source
packages/primitives/src/menu/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/menu/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/menu/behavior.tsthe behavior definition, menuItems(), menuInvoker(), closeMenu()packages/primitives/src/menu/register.tsthe registration module, the only side effectpackages/primitives/src/menu/validate.tsdevelopment validation messagespackages/styles/css/menu.cssthe optional stylesheet