sveltelean Primitives
Versionv0.2.0 GitHub

Example

Confirmation

Delete this project?

The project and its history are removed. This cannot be undone.

<button
	type="button"
	data-slean="button"
	data-variant="danger"
	commandfor="alert-dialog-delete"
	command="show-modal"
>
	Delete project
</button>

<dialog
	id="alert-dialog-delete"
	data-slean="alert-dialog"
	role="alertdialog"
	closedby="none"
	aria-labelledby="alert-dialog-delete-title"
	aria-describedby="alert-dialog-delete-description"
>
	<header data-slean-part="header">
		<h2 id="alert-dialog-delete-title" data-slean-part="title">Delete this project?</h2>
		<p id="alert-dialog-delete-description" data-slean-part="description">
			The project and its history are removed. This cannot be undone.
		</p>
	</header>
	<footer data-slean-part="footer">
		<button
			type="button"
			data-slean="button"
			data-variant="outline"
			commandfor="alert-dialog-delete"
			command="close"
			value="cancel"
			autofocus
		>
			Cancel
		</button>
		<button
			type="button"
			data-slean="button"
			data-variant="danger"
			commandfor="alert-dialog-delete"
			command="close"
			value="delete"
		>
			Delete project
		</button>
	</footer>
</dialog>
Tier 0: the two sources differ only by the stylesheet imports. Open it and press Escape: where closedby is implemented the dialog stays open. Focus starts on Cancel and returns to the button. This works with page JavaScript disabled where the browser implements invoker commands.

Why this implementation exists

A confirmation before an irreversible action should not disappear without an answer. <dialog> opened with showModal() already has the modal parts: the top layer, the inert page, focus moved in and returned. closedby="none" removes the close request, so Escape does not dismiss it, and a modal dialog never closes on a backdrop click unless closedby="any" asks for that.

Svelte Lean adds the role, the attributes and a narrower surface than the Dialog, with the actions in a footer and focus on the least destructive one. Each action closes the dialog with its value, which becomes the dialog’s returnValue.

The browser owns

  • opening through command="show-modal" and closing through command="close" with a value
  • the modal top layer, the backdrop and page inertness
  • no light dismiss and, where closedby is implemented, no Escape
  • moving focus to the autofocus action and returning it to the invoker
  • the returnValue of the chosen action

Svelte Lean owns

  • alert-dialog.css: the narrower surface, entrance and exit transitions, the layout parts
  • the contract and the AlertDialogPart type
  • documentation

Usage

The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.

npm install @svelte-lean/styles
stylesheets
import '@svelte-lean/styles/alert-dialog.css';

Open with command="show-modal". Give every action command="close" and a value, or make the actions submit buttons in a <form method="dialog">; read returnValue in a close listener.

Put autofocus on the least destructive action, so Enter right after opening cancels.

Where closedby is not implemented, Safari 27 among them, Escape closes the dialog without changing returnValue. Treat any value but the confirming one as a cancel, and reset returnValue after reading it.

The APG dialog pattern closes on Escape. To keep that, use closedby="closerequest": Escape cancels and the backdrop still does nothing.

Anatomy

PartElementdata-slean-partRequiredNotes
root<dialog id="…" role="alertdialog" closedby="none">–yesNeeds an id, aria-labelledby (title) and aria-describedby (description). Always opened as a modal.
invoker<button type="button" commandfor="<id>" command="show-modal">–yesOpens the dialog as a modal.
header<header>headerstyles onlyGroups the title and description.
title<h2>titlestyles onlyThe question; the element aria-labelledby points at.
description<p>descriptionstyles onlyThe consequence; the element aria-describedby points at.
footer<footer> or <form method="dialog">footerstyles onlyThe actions: command="close" buttons with a value each; autofocus on the least destructive one.

Runtime profile

Tier 0: the alert dialog has no behavior module, and the Vite plugin maps alert-dialog to no module. Opening, closing, focus and the return value are the browser’s.

Accessibility contract

  • role="alertdialog" with aria-labelledby on the title and aria-describedby on the description, so the question and its consequence are announced when the dialog opens.
  • Focus moves to the autofocus action when the dialog opens and returns to the invoker when it closes.
  • The rest of the page is inert while the dialog is open; Tab and Shift+Tab stay inside it.
  • Escape does nothing where closedby is implemented; the actions are the way out, and each is a labelled button.

Keyboard

KeyWhenResult
Enter/Spacefocus on the invokerOpens the dialog with focus on the autofocus action (native)
Tab/Shift+Tabdialog openMoves between the actions; the rest of the document is inert (native)
Enter/Spacefocus on an actionCloses the dialog with the action’s value as returnValue (native)
Escapedialog openNothing where closedby is implemented; elsewhere cancels without changing returnValue (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(). The package ships no polyfill.
closedbyChrome 134 and Firefox 141; not in Safari 27Escape cancels the dialog; returnValue keeps its previous value
overlay (exit transition)Chrome 117; not in Safari 27The exit transition runs after the dialog has left the top layer

Without JavaScript

With invoker commands supported, the dialog opens and closes with no script, and returnValue is set. Without invoker commands and without script it stays closed, so a confirmation that must work everywhere keeps a server-side confirmation step.

Server rendering

Static HTML. A modal dialog opens on the client only; the server renders it closed.

Before hydration

Nothing is attached. Where invoker commands are supported, the dialog opens before hydration as it does after.

Styling

alert-dialog.css draws the Dialog’s surface at a narrower width and without a scrolling body: header, title, description and the actions in the footer. The entrance and exit are transitions of opacity and scale with a fading backdrop; the durations are tokens and are zero under reduced motion. The tokens it reads:

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-20.5remgap
--slean-text-lg1.125remfont-size
--slean-font-weight-semibold600font-weight
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-text-sm0.875remfont-size
--slean-leading1.5line-height

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

Controlled integration

The answer is the dialog’s returnValue, read when it closes. No open state is mirrored.

delete-project.svelte
<script lang="ts">
	let dialog: HTMLDialogElement;
	let answer = $state('');

	// close fires after an action, with its value in returnValue. Where closedby="none" is not
	// implemented it also fires after Escape, with returnValue unchanged: reset it after reading,
	// and treat anything but the confirming value as a cancel.
	function onclose() {
		answer = dialog.returnValue;
		dialog.returnValue = '';
		if (answer === 'delete') {
			// delete the project
		}
	}
</script>

<dialog id="alert-dialog-delete" data-slean="alert-dialog" role="alertdialog" closedby="none"
	aria-labelledby="alert-dialog-delete-title" aria-describedby="alert-dialog-delete-description"
	bind:this={dialog} {onclose}>
	…
</dialog>

Compatibility notes

Invoker commands shipped in Chrome 135, Safari 26 and Firefox 144 during 2025. closedby is implemented in Chrome 134 and Firefox 141 and not in Safari 27, where Escape cancels the dialog. Safari 27 has no overlay either, so there the exit transition runs after the dialog has left the top layer.

Testing

Source