sveltelean Primitives
Versionv0.2.0 GitHub

Example

Account popover

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>
Tier 0: the two sources differ only by the stylesheet imports. Click outside or press Escape to dismiss; the button reports aria-expanded natively. Under RTL, data-slean-align="start" follows the direction. This works with page JavaScript disabled where the Popover API is supported.

Placement

Side and alignment

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>
data-slean-side and data-slean-align are read by the stylesheet only, through position-area against the invoker's implicit anchor; flip fallbacks keep the popover in the viewport. The first button uses command="toggle-popover", the invoker-commands form of popovertarget.

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.

confirm.html
<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>

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.html
<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/styles
app.css or +layout.svelte
import '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
import '@svelte-lean/styles/popover.css';
types only; registers nothing
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.

manual popover
<!-- 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

PartElementdata-slean-partRequiredNotes
root<div id="…" popover>–yespopover (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>">–yesProvides 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 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.

Keyboard

KeyWhenResult
Enter/Spacefocus on the invokerToggles the popover (native)
Escapeauto popover openCloses it (native)
Tab/Shift+Tabpopover openMoves through the content in document order; nothing is trapped (native)

Platform features

FeatureBaselineOutside 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-fallbacksChrome 125 and Safari 26; not Baseline at the time of writingThe popover keeps the platform default: centered in the top layer
popover="hint"Newer than the Baseline featureNot 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:

TokenDefault (light)Applies to
--slean-space-10.25remmargin-block, margin-inline
--slean-space-30.75rempadding
--slean-bordervar(--slean-neutral-6)border
--slean-popup-radiusvar(--slean-radius-md)border-radius
--slean-surfaceoklch(100% 0 0)background
--slean-fgvar(--slean-neutral-12)color
--slean-popup-shadowvar(--slean-shadow-lg)box-shadow
--slean-duration-fast100mstransition
--slean-easecubic-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.

account.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>

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

Source