sveltelean Primitives
Versionv0.2.0 GitHub

Example

Modal dialog

Settings

Changes apply to this device.

<button
	type="button"
	data-slean="button"
	commandfor="settings"
	command="show-modal"
>
	Settings
</button>

<dialog id="settings" data-slean="dialog" aria-labelledby="settings-title">
	<header data-slean-part="header">
		<h2 id="settings-title" data-slean-part="title">Settings</h2>
		<p data-slean-part="description">Changes apply to this device.</p>
	</header>
	<div data-slean-part="body">
		<label>
			<input type="checkbox" name="telemetry" data-slean="checkbox" />
			Send usage statistics
		</label>
	</div>
	<footer data-slean-part="footer">
		<button
			type="button"
			data-slean="button"
			data-variant="outline"
			commandfor="settings"
			command="close"
		>
			Close
		</button>
	</footer>
	<button
		type="button"
		data-slean-part="close"
		commandfor="settings"
		command="close"
		aria-label="Close"
	></button>
</dialog>
Tier 0: the two sources differ only by the stylesheet imports. Open it, press Escape, and watch focus return to the button; while it is open the rest of the page is inert. This works with page JavaScript disabled where the browser implements invoker commands.

Form with method dialog

Confirmation with a return value

Delete project

The project and its history are removed.

<button
	type="button"
	data-slean="button"
	data-variant="outline"
	commandfor="confirm"
	command="show-modal"
>
	Delete project
</button>

<dialog
	id="confirm"
	data-slean="dialog"
	data-size="sm"
	aria-labelledby="confirm-title"
	aria-describedby="confirm-description"
>
	<header data-slean-part="header">
		<h2 id="confirm-title" data-slean-part="title">Delete project</h2>
		<p id="confirm-description" data-slean-part="description">
			The project and its history are removed.
		</p>
	</header>
	<form method="dialog" data-slean-part="footer">
		<button type="submit" data-slean="button" data-variant="outline" value="cancel">Cancel</button>
		<button type="submit" data-slean="button" data-variant="danger" value="delete">Delete</button>
	</form>
</dialog>
A <form method="dialog"> closes the dialog on submit and stores the submit button's value in returnValue. The description is linked with aria-describedby; data-size="sm" is a stylesheet option.

An image preview (a lightbox) is a composition: a thumbnail button that opens a modal dialog holding the full image. The dialog owns the backdrop, Escape, page inertness and focus return; closedby="any" closes it on a click outside where the browser supports the attribute. No script.

The east pier at night, a lighthouse against the sky
lightbox.html
<button type="button" class="thumb" commandfor="photo" command="show-modal"
	aria-label="Enlarge: the east pier at night">
	<img src="/demo/card-harbor.svg" alt="" width="160" height="90" />
</button>
<dialog id="photo" data-slean="dialog" aria-label="The east pier at night" closedby="any">
	<img src="/demo/card-harbor.svg" alt="The east pier at night, a lighthouse against the sky"
		width="640" height="360" />
	<button type="button" commandfor="photo" command="close">Close</button>
</dialog>

Why this implementation exists

A modal needs a top layer above everything else, an inert page behind it, Escape to close, focus moved in on open and returned on close, and a backdrop. showModal() provides all five, and invoker commands let a button call it declaratively. Svelte Lean therefore adds no open-state controller, no focus trap, no portal, no z-index manager and no click listener on this path (ADR 0001). What remains is the stylesheet, the contract with its DialogCommand type, and the two-line fallback documented for browsers without commands.

The browser owns

  • opening and closing through command="show-modal" and command="close"
  • the modal top layer, the backdrop and page inertness
  • Escape (cancel, then close) and the returnValue of a <form method="dialog">
  • moving focus into the dialog and returning it to the invoker
  • dialog semantics and the aria-expanded relationship of the invoker

Svelte Lean owns

  • dialog.css: surface, sizes, entrance and exit transitions, the layout parts
  • the contract, the DialogCommand and DialogPart types
  • documentation

Usage

The markup needs no package. Install @svelte-lean/styles for the stylesheet and @svelte-lean/primitives for the typed contract; neither adds runtime JavaScript for a dialog.

npm install @svelte-lean/styles
npm install @svelte-lean/primitives
app.css or +layout.svelte
import '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
import '@svelte-lean/styles/dialog.css';
types only; registers nothing
import type { DialogCommand, DialogPart } from '@svelte-lean/primitives/dialog';

const command: DialogCommand = 'show-modal'; // 'show-modal' | 'close' | 'request-close'

Use a modal dialog for a task that must be completed or dismissed before the page is used again: confirmations, short forms, settings. Content that must be reachable without a script does not belong in a dialog. Non-modal dialogs (show() or the open attribute) are allowed by the contract but get no backdrop and no inertness, and the stylesheet assumes modal.

Anatomy

PartElementdata-slean-partRequiredNotes
root<dialog id="…">–yesNeeds an id for the invoker and an accessible name (aria-labelledby or aria-label).
invoker<button type="button" commandfor="<id>" command="show-modal">–yesOpens the dialog as a modal. command="close" inside the dialog closes it.
header<header>headerstyles onlyGroups the title and description.
title<h2>titlestyles onlyThe element aria-labelledby points at.
description<p>descriptionstyles onlyOptional; reference it with aria-describedby.
body<div>bodystyles onlyThe scrolling region.
footer<footer>footerstyles onlyHolds the close and submit buttons.

Runtime profile

The runtime block reads the tier and the events from the contract and the bytes from the native-only consumer fixture: a production Vite build of a page with button, dialog and popover markup and the Vite plugin, whose module graph contains no @svelte-lean/core or @svelte-lean/primitives module and whose emitted code contains no data-slean selector and no slean: event string. The primitives size script asserts the same for a bundle that imports the root entry and the dialog contract. Nothing is attached at hydration: no listener, no state object, no observer, no scroll lock. The listeners the proof page counts belong to the Tier 1 and Tier 2 behaviors (tabs, menu, listbox, combobox).

Accessibility contract

  • A <dialog> with an id and an accessible name: aria-labelledby pointing at the title, or aria-label. Optionally aria-describedby for the description.
  • Modal through command="show-modal". Closing through command="close" inside the dialog, or a <form method="dialog"> whose submit button closes it with a return value.
  • Focus: showModal() moves focus to the first focusable element or to the element with autofocus; closing returns focus to the element focused before opening. Put autofocus on the primary control when the first focusable element is not the right target.
  • The commandfor button exposes the relationship natively; aria-haspopup="dialog" may be added and is not required. A disabled invoker cannot open the dialog; controls inside behave natively.

Keyboard

KeyWhenResult
Enter/Spacefocus on the invokerOpens the dialog (native)
Escapedialog openCloses a modal dialog: cancel, then close (native)
Tab/Shift+Tabdialog openStays inside the dialog because the rest of the document is inert (native)

Platform features

FeatureBaselineOutside the target
<dialog>, showModal(), top layer, ::backdrop, page inertnessWidely available (newly available in 2022)Not applicable within the support policy
Invoker commands (command, commandfor)Newly available: Chrome 135, Safari 26 and Firefox 144 (2025); not yet widely availableThe dialog stays closed. Fallback: a two-line script calling showModal() and close(), or a future opt-in compatibility module (ADR 0005). The package ships no polyfill.
closedby, requestClose()Newer than the two commands aboveNot used; the contract does not depend on them

Without JavaScript

With invoker commands supported, the dialog opens and closes with no script at all; the playground's native page runs the dialog assertions with page JavaScript disabled. Without invoker commands and without script it stays closed, which is why content that must be reachable does not belong in a dialog.

Server rendering

Static HTML. Modal dialogs open on the client only; there is no declarative modal state, so the server never renders an open modal. open may be rendered server-side for a non-modal dialog. Ids are authored, never generated, so aria-labelledby and commandfor are correct in the server HTML (asserted by the playground's SSR spec).

Before hydration

The delayed-hydration test holds every script response for several seconds, opens the dialog through its invoker, checks :modal, presses Escape and asserts that focus returned to the invoker, all before any script has loaded. Hydration attaches nothing to a dialog, so a dialog opened before hydration is not touched by it.

Styling

dialog.css styles the native [open] state and ::backdrop: the entrance uses @starting-style, the exit keeps the element in the top layer through transition-behavior: allow-discrete on display and overlay. Sizes are data-size="sm" and "lg"; the five parts get their layout. The tokens it reads and the states it targets:

TokenDefault (light)Applies to
--slean-space-82.5reminline-size, max-block-size
--slean-space-61.5rempadding, margin-block-start
--slean-bordervar(--slean-neutral-6)border
--slean-radius-lg0.875remborder-radius
--slean-surfaceoklch(100% 0 0)background
--slean-fgvar(--slean-neutral-12)color
--slean-shadow-lg0 16px 40px oklch(0% 0 0 / 0.18), 0 2px 6px oklch(0% 0 0 / 0.08)box-shadow
--slean-duration-normal160mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-overlayoklch(0% 0 0 / 0.4)background
--slean-space-10.25remgap
--slean-space-41remmargin-block-end, inset-block-start, inset-inline-end
--slean-text-lg1.125remfont-size
--slean-font-weight-semibold600font-weight
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-text-sm0.875remfont-size
--slean-space-20.5remgap
--slean-control-height-sm2reminline-size, block-size, padding-inline-end
--slean-radius-sm0.375remborder-radius
--slean-duration-fast100mstransition
--slean-icon-closeurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 4.5l7 7m0-7l-7 7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask
--slean-mutedvar(--slean-neutral-3)background

State selectors the stylesheet targets, all from the platform or ARIA: ::backdrop, :hover, [open].

Variant attributes: data-size (sm, lg).

Controlled integration

The dialog's state is its open attribute and its returnValue. An application listens to the native close and cancel events and calls showModal() or close() when it has to open or close from code. The package mirrors nothing and dispatches no slean:* event for a dialog; a Svelte adapter is not built and is not needed for this primitive.

confirm.svelte
<script lang="ts">
	let dialog: HTMLDialogElement;
	let outcome = $state('');

	// The dialog dispatches close after command="close", Escape and a <form method="dialog">
	// submit; returnValue carries the submit button's value. No open state is mirrored.
	function onclose() {
		outcome = dialog.returnValue;
	}
</script>

<dialog id="confirm" data-slean="dialog" aria-labelledby="confirm-title" bind:this={dialog} {onclose}>
	…
</dialog>

Compatibility notes

<dialog>, showModal(), the top layer, ::backdrop and inertness are Baseline widely available. Invoker commands are newly available (Chrome 135, Safari 26, Firefox 144) and not yet widely available; where they are missing the dialog stays closed. The documented fallback is the two calls a command would make; the package does not polyfill the attribute, and an opt-in compatibility module is the planned path (ADR 0005). The closedby attribute and requestClose() are newer and not depended on. The policy is on Browser support.

fallback where commands are missing
<!-- Only where invoker commands are missing: the same two calls the command would make. -->
<button type="button" onclick={() => settings.showModal()}>Settings</button>
<dialog id="settings" data-slean="dialog" bind:this={settings}>
	…
	<button type="button" onclick={() => settings.close()}>Close</button>
</dialog>

Note The playground suite runs in Chromium (Google Chrome locally, Playwright's Chromium in CI). Firefox and WebKit runs do not exist yet and are not claimed.

Testing

Source