Primitives Overlays
Popover
A native [popover] element shown by a popovertarget button and placed with CSS anchor positioning. The browser owns showing, hiding, light dismiss and the top layer; the package ships placement options for the stylesheet, the contract, and no runtime. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - 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
On this page
Example
Signed in as maintainer.
Manage account<button
type="button"
data-slean="button"
data-variant="outline"
popovertarget="account"
>
Account
</button>
<div id="account" popover data-slean="popover" data-slean-side="bottom" data-slean-align="start">
<p>Signed in as <strong>maintainer</strong>.</p>
<a href="/docs">Manage account</a>
</div><script lang="ts">
// Nothing to import: 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="outline"
popovertarget="account"
>
Account
</button>
<div id="account" popover data-slean="popover" data-slean-side="bottom" data-slean-align="start">
<p>Signed in as <strong>maintainer</strong>.</p>
<a href="/docs">Manage account</a>
</div>Placement
Opened with command="toggle-popover".
Placed above the invoker, aligned to its end edge.
<button
type="button"
data-slean="button"
data-variant="outline"
commandfor="hint"
command="toggle-popover"
>
Right, start
</button>
<div id="hint" popover data-slean="popover" data-slean-side="right" data-slean-align="start">
<p>Opened with <code>command="toggle-popover"</code>.</p>
</div>
<button type="button" data-slean="button" data-variant="outline" popovertarget="above">
Top, end
</button>
<div id="above" popover data-slean="popover" data-slean-side="top" data-slean-align="end">
<p>Placed above the invoker, aligned to its end edge.</p>
</div><script lang="ts">
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/popover.css';
</script>
<button
type="button"
data-slean="button"
data-variant="outline"
commandfor="hint"
command="toggle-popover"
>
Right, start
</button>
<div id="hint" popover data-slean="popover" data-slean-side="right" data-slean-align="start">
<p>Opened with <code>command="toggle-popover"</code>.</p>
</div>
<button type="button" data-slean="button" data-variant="outline" popovertarget="above">
Top, end
</button>
<div id="above" popover data-slean="popover" data-slean-side="top" data-slean-align="end">
<p>Placed above the invoker, aligned to its end edge.</p>
</div>Confirmation
A popconfirm is a composition, not a primitive: a popover with a question and two buttons.
Cancel hides the popover with popovertargetaction="hide"; the confirming button
submits a form, which is where the action belongs. With role="dialog" and a name, the
question is announced when focus moves into it. No script. The example on this page hides the popover
from both buttons, since the site has no server to post to.
Delete the project and its history?
<button type="button" data-slean="button" data-variant="danger"
popovertarget="delete-confirm">Delete project</button>
<div id="delete-confirm" popover role="dialog" aria-labelledby="delete-confirm-title"
data-slean="popover" data-slean-side="bottom" data-slean-align="start">
<p id="delete-confirm-title">Delete the project and its history?</p>
<button type="button" data-slean="button" data-variant="outline"
popovertarget="delete-confirm" popovertargetaction="hide">Cancel</button>
<form method="post" action="?/delete">
<button data-slean="button" data-variant="danger">Delete</button>
</form>
</div>Navigation menu
A site navigation with dropdowns is not an ARIA menu: it is a <nav> of links
where some entries open a panel of more links (the WAI-ARIA disclosure navigation pattern). A
button with popovertarget opens each panel; the platform exposes the expanded state,
closes the panel on a click outside or Escape and returns focus to the button, and opening another
panel closes the first. Tab moves through the links in order. No script.
<nav aria-label="Main">
<ul>
<li>
<button type="button" popovertarget="nav-products">Products</button>
<div id="nav-products" popover data-slean="popover" data-slean-align="start">
<ul>
<li><a href="/primitives">Primitives</a></li>
<li><a href="/table">Table</a></li>
</ul>
</div>
</li>
<li><a href="/docs">Docs</a></li>
</ul>
</nav>Why this implementation exists
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 relationship 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, and the side and align placement options
- the contract and the PopoverSide, PopoverAlign, PopoverMode and PopoverCommand types
- documentation
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet and @svelte-lean/primitives for the typed placement options.
npm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
import '@svelte-lean/styles/popover.css';import type { PopoverAlign, PopoverSide } from '@svelte-lean/primitives/popover';
const side: PopoverSide = 'bottom'; // 'top' | 'bottom' | 'left' | 'right'
const align: PopoverAlign = 'start'; // 'start' | 'center' | 'end'Use a popover for content anchored to a control that the user can dismiss by clicking away: an
account panel, a hint, a color picker. 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, and needs its
own hide control.
<!-- popover="manual": no light dismiss, no Escape; hide it with popovertargetaction. -->
<button type="button" popovertarget="notice" popovertargetaction="show">Show notice</button>
<div id="notice" popover="manual" data-slean="popover">
<p>Stays open until hidden.</p>
<button type="button" popovertarget="notice" popovertargetaction="hide">Dismiss</button>
</div>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. |
Runtime profile
The runtime block reads the tier and the events from the contract and the bytes from the
native-only consumer fixture, whose markup includes this popover: a production Vite build with
the Vite plugin, whose module graph contains no @svelte-lean/core or @svelte-lean/primitives module. Nothing is attached at hydration: no listener, no
observer, no measurement of the invoker. Placement is computed by the browser's layout engine,
not by JavaScript. The listeners the proof page counts belong to the two Tier
1 behaviors.
Accessibility contract
- The root has an
idand thepopoverattribute; the invoker carriespopovertarget(orcommandfor), which providesaria-expanded, focus return and the implicit anchor. - Showing a popover does not move focus unless the popover or a descendant has
autofocus; hiding anautopopover 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.
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) |
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, which is visible and reachable, not lost.
Server rendering
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.
Before hydration
The delayed-hydration test opens the popover through its invoker, checks :popover-open, dismisses it with Escape and follows a link inside it, all while
every script response is held back. Hydration attaches nothing to a popover.
Styling
popover.css styles [data-slean="popover"] and, because a menu is a
popover, [data-slean="menu"]: the surface, the :popover-open transition with @starting-style, and placement through position-area with position-try-fallbacks: flip-block, flip-inline.
Sides are physical (top, bottom, left, right); alignment is logical (start, end). Where position-area is unsupported an @supports block restores the platform default:
centered in the top layer. The tokens it reads and the states it targets:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-1 | 0.25rem | margin-block, margin-inline |
--slean-space-3 | 0.75rem | padding |
--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-shadow-lg) | box-shadow |
--slean-duration-fast | 100ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
State selectors the stylesheet targets, all from the platform or ARIA: :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. An application listens to the native beforetoggle and toggle events (ToggleEvent with newState) and calls showPopover(), hidePopover() or togglePopover() from code. The package mirrors nothing and dispatches no slean:* event for a popover; a Svelte adapter is not built and is not needed for this
primitive.
<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>Compatibility notes
The Popover API is Baseline newly available since April 2024 (Chrome 114, Safari 17, Firefox
125). CSS anchor positioning is Chrome 125 and Safari 26 and not Baseline at the time of
writing; where it is missing the popover keeps the platform default placement, centered in the
top layer, and nothing else changes. popover="hint" is newer than the Baseline
feature and is not used. The policy is on Browser support.
Note The playground suite runs in Chromium (Google Chrome locally, Playwright's Chromium in CI). Firefox and WebKit runs, which would exercise the centered fallback, do not exist yet.
Testing
apps/playground/tests/primitives/native.spec.tsPlaywright, with page JavaScript enabled and disabledapps/playground/tests/primitives/a11y.spec.tsaxe on every playground page and on open dialog, popover and menu statesfixtures/native-onlyconsumer build asserting that no behavior runtime ships (invariant A)packages/primitives/scripts/size.mjsthe native-only build that must contain no runtime
Source
packages/primitives/src/popover/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/popover/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/popover.cssthe optional stylesheet