# Popover

> The popover attribute (auto and manual), popovertarget, CSS anchor positioning with position-area, and the fallback without anchor positioning.

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

Content anchored to a button and dismissed by clicking away: an account panel, a confirmation, a small form. The browser shows, hides and layers it.

```svelte
<script lang="ts">
	// No runtime: the browser owns the popover. The stylesheets are optional.
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/popover.css';
</script>

<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" popovertarget="account">
	Account
</button>
<div id="account" popover data-slean="popover" data-slean-side="bottom" data-slean-align="start">
	<header>
		<h3>Ada Lovelace</h3>
		<p>Signed in as ada@example.com</p>
	</header>
	<a href="/docs">Manage account</a>
	<footer>
		<button type="button" data-slean="button" data-variant="text" popovertarget="account" popovertargetaction="hide">
			Sign out
		</button>
	</footer>
</div>
```

Click outside or press Escape to dismiss; the button reports aria-expanded natively. Under RTL, data-slean-align="start" follows the direction.

## Examples

### Placement

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

	const sides = ['top', 'right', 'bottom', 'left'] as const;
</script>

<div class="sides">
	{#each sides as side (side)}
		<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" popovertarget="side-{side}">
			{side}
		</button>
		<div id="side-{side}" popover data-slean="popover" data-slean-side={side}>
			Placed on the {side} side.
		</div>
	{/each}
</div>

<style>
	.sides {
		display: flex;
		flex-wrap: wrap;
		gap: 0.75rem;
	}
</style>
```

data-slean-side is read by the stylesheet only, through position-area against the invoker’s implicit anchor; flip fallbacks keep the popover in the viewport.

### Confirmation

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

	let deleted = $state(false);
</script>

<button type="button" data-slean="button" data-color="error" popovertarget="confirm-delete">
	Delete project
</button>
<div
	id="confirm-delete"
	popover
	role="dialog"
	aria-labelledby="confirm-delete-title"
	data-slean="popover"
	data-slean-align="start"
>
	<header>
		<h3 id="confirm-delete-title">Delete this project?</h3>
		<p>The project and its history are removed.</p>
	</header>
	<footer>
		<button
			type="button"
			data-slean="button"
			data-variant="outlined"
			data-color="neutral"
			data-size="small"
			popovertarget="confirm-delete"
			popovertargetaction="hide"
		>
			Cancel
		</button>
		<button
			type="button"
			data-slean="button"
			data-color="error"
			data-size="small"
			popovertarget="confirm-delete"
			popovertargetaction="hide"
			onclick={() => (deleted = true)}
		>
			Delete
		</button>
	</footer>
</div>
<p role="status">{deleted ? 'The project was deleted.' : ''}</p>
```

A popover with role="dialog" and a name: the question is announced when focus moves into it.

### Form in a popover

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

	let name = $state('Roadmap');
	let draft = $state('');
	let panel: HTMLElement;

	function save(event: SubmitEvent) {
		event.preventDefault();
		name = draft.trim() || name;
		panel.hidePopover();
	}
</script>

<p>Project: <strong>{name}</strong></p>
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" popovertarget="rename">
	Rename
</button>
<div
	id="rename"
	popover
	role="dialog"
	aria-label="Rename the project"
	data-slean="popover"
	data-slean-align="start"
	bind:this={panel}
	ontoggle={(event) => {
		if ((event as ToggleEvent).newState === 'open') draft = name;
	}}
>
	<form class="rename" onsubmit={save}>
		<div data-slean="field">
			<label for="rename-name" data-slean-part="label">Name</label>
			<input id="rename-name" data-slean="input" data-size="small" bind:value={draft} />
		</div>
		<button type="submit" data-slean="button" data-size="small">Save</button>
	</form>
</div>

<style>
	.rename {
		display: flex;
		align-items: end;
		gap: 0.5rem;
	}
</style>
```

The toggle event fills the draft on open; hidePopover() closes it after the submit.

### Navigation menu

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

<!-- A disclosure navigation, not an ARIA menu: links, and buttons that open panels of links. -->
<nav aria-label="Products" class="site-nav">
	<ul>
		<li>
			<button type="button" data-slean="button" data-variant="text" data-color="neutral" popovertarget="nav-build">
				Build
			</button>
			<div id="nav-build" popover data-slean="popover" data-slean-align="start">
				<ul>
					<li><a href="/primitives">Primitives</a></li>
					<li><a href="/table">Table</a></li>
					<li><a href="/styling">Styling</a></li>
				</ul>
			</div>
		</li>
		<li>
			<button type="button" data-slean="button" data-variant="text" data-color="neutral" popovertarget="nav-learn">
				Learn
			</button>
			<div id="nav-learn" popover data-slean="popover" data-slean-align="start">
				<ul>
					<li><a href="/docs">Introduction</a></li>
					<li><a href="/docs/installation">Installation</a></li>
				</ul>
			</div>
		</li>
		<li><a href="/performance">Performance</a></li>
	</ul>
</nav>

<style>
	.site-nav > ul {
		display: flex;
		align-items: center;
		gap: 1rem;
	}

	.site-nav ul {
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.site-nav [popover] ul {
		display: grid;
		gap: 0.5rem;
	}
</style>
```

The WAI-ARIA disclosure navigation pattern: opening one panel closes the other, Tab moves through the links in order.

### Manual popover

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

<!-- popover="manual": no light dismiss and no Escape; it stays until hidden. -->
<button
	type="button"
	data-slean="button"
	data-variant="outlined"
	data-color="neutral"
	popovertarget="draft-status"
	popovertargetaction="show"
>
	Draft status
</button>
<div id="draft-status" popover="manual" data-slean="popover" data-slean-side="right">
	<p>Your changes are saved as a draft until you publish.</p>
	<button
		type="button"
		data-slean="button"
		data-size="small"
		popovertarget="draft-status"
		popovertargetaction="hide"
	>
		Got it
	</button>
</div>
```

No light dismiss and no one-at-a-time rule: the popover needs its own hide control.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

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

`stylesheets`

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

Give the popover an `id` and `popover`, and its button `popovertarget` (or `commandfor` with `command="toggle-popover"`). `data-slean-side` (top, right, bottom, left) and `data-slean-align` (start, center, end) place it; `@svelte-lean/primitives` types them.

Give the popover a role when it is a widget (Menu is the packaged case); this contract is for plain content. `popover="manual"` opts out of light dismiss and of the one-at-a-time rule.

`types only; registers nothing`

```ts
import type { PopoverAlign, PopoverSide } from '@svelte-lean/primitives/popover';

const side: PopoverSide = 'bottom'; // 'top' | 'bottom' | 'left' | 'right'
const align: PopoverAlign = 'start'; // 'start' | 'center' | 'end'
```

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<div id="…" popover>` | – | yes | popover (auto) by default. data-slean="popover" marks it for the styles; data-slean-side and data-slean-align are placement hints. |
| invoker | `<button type="button" popovertarget="<id>">` | – | yes | Provides aria-expanded, focus return and the anchor. commandfor with command="toggle-popover" is equivalent where invoker commands exist. |
| header | `<header> (a child of the popover)` | `header` | styles only | Matched by element: the title and description above the content. |
| title | `the first heading of the header` | `title` | styles only | Name the popover with it (aria-labelledby) when it has role="dialog". |
| description | `a <p> right after the title` | `description` | styles only | Muted text under the title. |
| footer | `<footer> (a child of the popover)` | `footer` | styles only | Actions at the inline end. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Enter`/`Space` | focus on the invoker | Toggles the popover (native) |
| `Escape` | auto popover open | Closes it (native) |
| `Tab`/`Shift+Tab` | popover open | Moves through the content in document order; nothing is trapped (native) |

## Styling

`popover.css` styles `[data-slean="popover"]` and, because a menu and a hover card are popovers, `[data-slean="menu"]` and `[data-slean="hover-card"]`: the surface, a child `<header>` (its heading is the title, a `<p>` after it the description) and `<footer>` (actions at the inline end) matched by element, the `:popover-open` transition with `@starting-style`, and placement through `position-area` with `position-try-fallbacks: flip-block, flip-inline`. Sides are physical; alignment is logical. Where `position-area` is unsupported an `@supports` block restores the platform default: centered in the top layer. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | margin-block, max-block-size, margin-inline, gap |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding, margin-block-end, margin-block-start |
| `--slean-border` | `var(--slean-neutral-6)` | border |
| `--slean-popup-radius` | `var(--slean-radius-md)` | border-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-popup-shadow` | `var(--slean-elevation-3)` | box-shadow |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-font-sans` | `Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | font-family |
| `--slean-typography-subtitle1-size` | `1rem` | font-size |
| `--slean-typography-subtitle1-weight` | `500` | font-weight |
| `--slean-typography-subtitle1-line-height` | `1.5` | line-height |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-typography-body2-size` | `0.875rem` | font-size |
| `--slean-typography-body2-weight` | `400` | font-weight |
| `--slean-typography-body2-line-height` | `1.45` | line-height |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap |

State selectors the stylesheet targets, all from the platform or ARIA: `:first-child`, `:popover-open`.

Variant attributes: `data-slean-side` (`top`, `left`, `right`); `data-slean-align` (`start`, `end`).

## Controlled integration

The popover’s state is whether it is showing. Listen to the native `beforetoggle` and `toggle` events (`ToggleEvent` with `newState`) and call `showPopover()`, `hidePopover()` or `togglePopover()` from code. The package mirrors nothing and dispatches no `slean:*` event for a popover.

`account.svelte`

```svelte
<script lang="ts">
	let open = $state(false);

	// The popover dispatches beforetoggle and toggle (ToggleEvent) on every show and hide,
	// including light dismiss and Escape. Read newState; nothing is mirrored by the package.
	function ontoggle(event: ToggleEvent) {
		open = event.newState === 'open';
	}
</script>

<button type="button" data-slean="button" popovertarget="account">Account</button>
<div id="account" popover data-slean="popover" {ontoggle}>…</div>
```

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `popover`, `popovertarget`, `:popover-open`, `position-area`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `[popover] + popovertarget or command/commandfor`

Tier 0: nothing is attached at hydration, with no listener, no observer and no measurement of the invoker. Placement is computed by the browser’s layout engine, not by JavaScript. The native-only consumer fixture, whose markup includes a popover, builds without any `@svelte-lean/core` or `@svelte-lean/primitives` module.

Why this implementation

A popover used to require a portal, a click-outside listener, an Escape handler, a z-index scheme and a positioning engine that measures the trigger, listens to scroll and resize, and computes collisions.

The Popover API provides the top layer, light dismiss, Escape, `aria-expanded` and focus return; the invoker gives the popover an implicit anchor, and CSS anchor positioning places it with `position-area` and flip fallbacks. Svelte Lean therefore ships no positioning engine, no scroll or resize listener and no dismiss logic (ADR 0001); the two placement options are attributes the stylesheet reads.

### The browser owns

- showing and hiding through popovertarget or command="toggle-popover"
- the top layer, light dismiss on outside click and Escape, one auto popover at a time
- aria-expanded on the invoker and focus return when the popover contained focus
- the implicit anchor between invoker and popover
- placement, through CSS anchor positioning in the stylesheet

### Svelte Lean owns

- popover.css: surface, transition, header and footer matched by element, and the side and align placement options
- the contract and the PopoverSide, PopoverAlign, PopoverMode, PopoverCommand and PopoverPart types
- documentation

Accessibility

- The root has an `id` and the `popover` attribute; the invoker carries `popovertarget` (or `commandfor`), which provides `aria-expanded`, focus return and the implicit anchor.
- Showing a popover does not move focus unless the popover or a descendant has `autofocus`; hiding an `auto` popover that contains focus returns focus to the invoker.
- Movement inside the popover is not part of this contract: Tab moves through the content in document order. A menu, listbox or dialog inside a popover takes its own role and contract.
- A disabled invoker cannot open the popover.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Popover API: popover, popovertarget, popovertargetaction, :popover-open, ToggleEvent, showPopover() and hidePopover()` | Newly available since April 2024 (Chrome 114, Safari 17, Firefox 125) | The attribute is ignored and the content renders inline |
| `CSS anchor positioning: position-area, position-try-fallbacks` | Chrome 125 and Safari 26; not Baseline at the time of writing | The popover keeps the platform default: centered in the top layer |
| `popover="hint"` | Newer than the Baseline feature | Not used; do not depend on it |

Without JavaScript

Opens and closes with no script wherever the Popover API is supported; the playground’s native page runs the popover assertions, including light dismiss, with page JavaScript disabled. Without support the attribute is ignored and the content renders inline: visible and reachable, not lost.

Server rendering and hydration

Static HTML; a popover is closed on load because there is no declarative open state. Ids are authored, never generated, so `popovertarget` is correct in the server HTML.

The delayed-hydration test opens the popover through its invoker, checks `:popover-open`, dismisses it with Escape and follows a link inside it while every script response is held back. Hydration attaches nothing to a popover.

Compatibility

The Popover API is Baseline 2024 (Chrome 114, Safari 17, Firefox 125). CSS anchor positioning is Chrome 125 and Safari 26 and not Baseline; without it the popover keeps the platform’s centered placement and nothing else changes. `popover="hint"` is newer than the Baseline feature and is not used. The playground suite runs in Chromium; Firefox and WebKit runs, which would exercise the centered fallback, do not exist yet.

Tests and source

- `apps/playground/tests/primitives/native.spec.ts` Playwright, with page JavaScript enabled and disabled
- `apps/playground/tests/primitives/a11y.spec.ts` axe on every playground page and on open dialog, popover and menu states
- `fixtures/native-only` consumer build asserting that no behavior runtime ships (invariant A)
- `packages/primitives/scripts/size.mjs` the native-only build that must contain no runtime

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