Primitives Overlays
Dialog
A native <dialog> opened and closed by invoker command buttons. The browser provides the modal top layer, page inertness, Escape, focus movement and return, and the backdrop; the package contributes types, the contract and layout parts for the stylesheet, and no runtime. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - Platform features
<dialog>,showModal(),command,commandfor,top layer,::backdrop- Shared listeners
- none
- Per-instance listeners
- none
- Lazy state
- none
- Native base
<dialog> + command/commandfor
On this page
Example
<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><script lang="ts">
// Nothing to import: the browser owns the dialog. The stylesheets are optional.
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/checkbox.css';
import '@svelte-lean/styles/dialog.css';
</script>
<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>Form with method dialog
<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><script lang="ts">
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/dialog.css';
</script>
<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>Image preview
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.
<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/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesnpm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesimport '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
import '@svelte-lean/styles/dialog.css';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
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <dialog id="…"> | – | yes | Needs an id for the invoker and an accessible name (aria-labelledby or aria-label). |
| invoker | <button type="button" commandfor="<id>" command="show-modal"> | – | yes | Opens the dialog as a modal. command="close" inside the dialog closes it. |
| header | <header> | header | styles only | Groups the title and description. |
| title | <h2> | title | styles only | The element aria-labelledby points at. |
| description | <p> | description | styles only | Optional; reference it with aria-describedby. |
| body | <div> | body | styles only | The scrolling region. |
| footer | <footer> | footer | styles only | Holds 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 anidand an accessible name:aria-labelledbypointing at the title, oraria-label. Optionallyaria-describedbyfor the description. - Modal through
command="show-modal". Closing throughcommand="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 withautofocus; closing returns focus to the element focused before opening. Putautofocuson the primary control when the first focusable element is not the right target. - The
commandforbutton 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
| Key | When | Result |
|---|---|---|
| Enter/Space | focus on the invoker | Opens the dialog (native) |
| Escape | dialog open | Closes a modal dialog: cancel, then close (native) |
| Tab/Shift+Tab | dialog open | Stays inside the dialog because the rest of the document is inert (native) |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
<dialog>, showModal(), top layer, ::backdrop, page inertness | Widely 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 available | The 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 above | Not 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:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-8 | 2.5rem | inline-size, max-block-size |
--slean-space-6 | 1.5rem | padding, margin-block-start |
--slean-border | var(--slean-neutral-6) | border |
--slean-radius-lg | 0.875rem | border-radius |
--slean-surface | oklch(100% 0 0) | background |
--slean-fg | var(--slean-neutral-12) | color |
--slean-shadow-lg | 0 16px 40px oklch(0% 0 0 / 0.18), 0 2px 6px oklch(0% 0 0 / 0.08) | box-shadow |
--slean-duration-normal | 160ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-overlay | oklch(0% 0 0 / 0.4) | background |
--slean-space-1 | 0.25rem | gap |
--slean-space-4 | 1rem | margin-block-end, inset-block-start, inset-inline-end |
--slean-text-lg | 1.125rem | font-size |
--slean-font-weight-semibold | 600 | font-weight |
--slean-fg-muted | var(--slean-neutral-11) | color |
--slean-text-sm | 0.875rem | font-size |
--slean-space-2 | 0.5rem | gap |
--slean-control-height-sm | 2rem | inline-size, block-size, padding-inline-end |
--slean-radius-sm | 0.375rem | border-radius |
--slean-duration-fast | 100ms | transition |
--slean-icon-close | url("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-muted | var(--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.
<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.
<!-- 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
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/dialog/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/dialog/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/dialog.cssthe optional stylesheet