# Dialog

> Native <dialog> in three shapes: centered, a drawer at an edge (data-slean-side), an alert dialog (role="alertdialog" closedby="none"); top layer, Escape and focus return, form method dialog, no runtime.

Page: https://svelte-lean.vercel.app/primitives/dialog · Markdown: https://svelte-lean.vercel.app/primitives/dialog.md · Index: https://svelte-lean.vercel.app/llms.txt

A native modal dialog, centered, at an edge as a drawer, or as an alert dialog that asks for a decision.

```svelte
<script lang="ts">
	// Nothing to import for the dialog itself: the browser owns it. 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" closedby="any" aria-labelledby="settings-title">
	<header>
		<h2 id="settings-title">Settings</h2>
		<p>Changes apply to this device.</p>
	</header>
	<div class="options">
		<label><input type="checkbox" name="telemetry" data-slean="checkbox" /> Send usage statistics</label>
		<label><input type="checkbox" name="sounds" data-slean="checkbox" checked /> Play sounds</label>
	</div>
	<footer>
		<button type="button" data-slean="button" data-variant="text" commandfor="settings" command="close">
			Cancel
		</button>
		<button type="button" data-slean="button" commandfor="settings" command="close">Save</button>
	</footer>
	<button type="button" data-slean-part="close" commandfor="settings" command="close" aria-label="Close"></button>
</dialog>

<style>
	.options {
		display: grid;
		gap: 0.75rem;
	}
</style>
```

Open it and press Escape: focus returns to the button, and the page behind is inert. No script where the browser has invoker commands.

## Examples

### Drawer

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/checkbox.css';
	import '@svelte-lean/styles/dialog.css';

	const teams = ['Engineering', 'Design', 'Operations', 'Growth'];
</script>

<button type="button" data-slean="button" data-variant="outlined" commandfor="filters" command="show-modal">
	Filters
</button>

<!-- data-slean-side makes it a drawer; closedby="any" closes it on a click outside. -->
<dialog id="filters" data-slean="dialog" data-slean-side="right" closedby="any" aria-labelledby="filters-title">
	<header>
		<h2 id="filters-title">Filters</h2>
		<p>Narrow the list of people.</p>
	</header>
	<fieldset class="options">
		<legend>Team</legend>
		{#each teams as team (team)}
			<label><input type="checkbox" name="team" value={team} data-slean="checkbox" /> {team}</label>
		{/each}
	</fieldset>
	<footer>
		<button type="button" data-slean="button" commandfor="filters" command="close">Show results</button>
	</footer>
	<button type="button" data-slean-part="close" commandfor="filters" command="close" aria-label="Close"></button>
</dialog>

<style>
	.options {
		display: grid;
		gap: 0.75rem;
		margin: 0;
		padding: 0;
		border: 0;
	}
</style>
```

data-slean-side attaches the dialog to an edge; closedby="any" closes it on a click outside.

### Sides

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/dialog.css';

	// The side is physical: right stays right in a right-to-left page.
	const sides = ['left', 'top', 'bottom'] as const;
</script>

{#each sides as side (side)}
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" commandfor="drawer-{side}" command="show-modal">
		{side}
	</button>
	<dialog id="drawer-{side}" data-slean="dialog" data-slean-side={side} closedby="any" aria-labelledby="drawer-{side}-title">
		<header><h2 id="drawer-{side}-title">Drawer at the {side}</h2></header>
		<p>{side === 'left' ? 'A side panel, as tall as the viewport.' : 'A sheet, as tall as its content.'}</p>
		<footer>
			<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" commandfor="drawer-{side}" command="close">
				Close
			</button>
		</footer>
	</dialog>
{/each}
```

The side is physical: it does not flip in a right-to-left page.

### Alert dialog

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/dialog.css';

	let answer = $state('none yet');

	// returnValue holds the value of the action that closed the dialog. Reset it after reading:
	// where closedby is not implemented, Escape closes without changing it.
	function onclose(event: Event & { currentTarget: HTMLDialogElement }) {
		answer = event.currentTarget.returnValue || 'cancel';
		event.currentTarget.returnValue = '';
	}
</script>

<button type="button" data-slean="button" data-color="error" commandfor="delete-project" command="show-modal">
	Delete project
</button>
<p role="status">Answer: {answer}</p>

<!-- role="alertdialog" and closedby="none": only the two actions close it. -->
<dialog
	id="delete-project"
	data-slean="dialog"
	role="alertdialog"
	closedby="none"
	aria-labelledby="delete-project-title"
	aria-describedby="delete-project-text"
	{onclose}
>
	<header>
		<h2 id="delete-project-title">Delete this project?</h2>
		<p id="delete-project-text">The project and its history are removed. This cannot be undone.</p>
	</header>
	<footer>
		<!-- svelte-ignore a11y_autofocus -->
		<button type="button" data-slean="button" data-variant="text" data-color="neutral" commandfor="delete-project" command="close" value="cancel" autofocus>
			Cancel
		</button>
		<button type="button" data-slean="button" data-color="error" commandfor="delete-project" command="close" value="delete">
			Delete project
		</button>
	</footer>
</dialog>
```

role="alertdialog" and closedby="none": Escape and a click outside do nothing; focus starts on Cancel; the action's value is the returnValue.

### Form

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
	import '@svelte-lean/styles/dialog.css';

	let name = $state('Ada Byron');
	let email = $state('ada@example.com');
	let saved = $state({ name: 'Ada Byron', email: 'ada@example.com' });

	// Save submits the form (the browser checks required and type="email" first); Cancel, Escape
	// and the close button keep the saved values.
	function onclose(event: Event & { currentTarget: HTMLDialogElement }) {
		if (event.currentTarget.returnValue === 'save') saved = { name, email };
		else ({ name, email } = saved);
		event.currentTarget.returnValue = '';
	}
</script>

<button type="button" data-slean="button" data-variant="outlined" commandfor="profile" command="show-modal">
	Edit profile
</button>
<p>{saved.name}, {saved.email}</p>

<!-- A form method="dialog" child keeps header and footer as the dialog's own. -->
<dialog id="profile" data-slean="dialog" aria-labelledby="profile-title" {onclose}>
	<form method="dialog">
		<header><h2 id="profile-title">Edit profile</h2></header>
		<div class="fields">
			<div data-slean="field">
				<label for="profile-name" data-slean-part="label">Name</label>
				<input id="profile-name" data-slean="input" required bind:value={name} />
			</div>
			<div data-slean="field">
				<label for="profile-email" data-slean-part="label">Email</label>
				<input id="profile-email" type="email" data-slean="input" required bind:value={email} />
			</div>
		</div>
		<footer>
			<button type="submit" data-slean="button" data-variant="text" value="cancel" formnovalidate>Cancel</button>
			<button type="submit" data-slean="button" value="save">Save</button>
		</footer>
	</form>
</dialog>

<style>
	.fields {
		display: grid;
		gap: 1rem;
	}
</style>
```

The browser validates the fields before the dialog closes; Cancel skips validation.

### Scrolling content

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/dialog.css';

	const sections = [
		['Accounts', 'You are responsible for the activity of your account and for keeping its password private.'],
		['Content', 'You keep the rights to what you upload. We store it and show it to the people you share it with.'],
		['Acceptable use', 'Do not use the service to send spam, to break the law or to probe the security of other customers.'],
		['Payments', 'Paid plans renew each month until cancelled. A cancelled plan stays active until the period ends.'],
		['Refunds', 'A plan cancelled within fourteen days of its first payment is refunded in full.'],
		['Data', 'We keep your data in the region you choose. An export of every project is available at any time.'],
		['Availability', 'We announce planned maintenance a week in advance on the status page.'],
		['Termination', 'You can close your account at any time. We delete its data within thirty days.'],
		['Changes', 'We announce changes to these terms by email at least two weeks before they apply.'],
		['Privacy', 'We collect only what the service needs to run and never sell it.'],
		['Support', 'Paid plans include support by email on working days.'],
		['Liability', 'Our liability is limited to the amount you paid in the twelve months before a claim.']
	];
</script>

<button type="button" data-slean="button" data-variant="outlined" commandfor="terms" command="show-modal">
	Read the terms
</button>

<!-- The dialog scrolls; its header, its footer and the close button stay in view. -->
<dialog id="terms" data-slean="dialog" data-size="large" aria-labelledby="terms-title">
	<header>
		<h2 id="terms-title">Terms of service</h2>
		<p>Last updated on 1 October 2026.</p>
	</header>
	{#each sections as [title, text] (title)}
		<h3>{title}</h3>
		<p>{text}</p>
	{/each}
	<footer>
		<button type="button" data-slean="button" data-variant="text" commandfor="terms" command="close">Decline</button>
		<button type="button" data-slean="button" commandfor="terms" command="close">Accept</button>
	</footer>
	<button type="button" data-slean-part="close" commandfor="terms" command="close" aria-label="Close"></button>
</dialog>
```

The dialog scrolls; its header, its footer and the close button stay in view.

### Image preview

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/dialog.css';
</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" data-size="large" closedby="any" aria-label="The east pier at night">
	<img src="/demo/card-harbor.svg" alt="The east pier at night, a lighthouse against the sky" width="640" height="360" />
	<button type="button" data-slean-part="close" commandfor="photo" command="close" aria-label="Close"></button>
</dialog>

<style>
	.thumb {
		padding: 0;
		border: 1px solid var(--slean-border);
		border-radius: var(--slean-radius-sm);
		background: none;
		cursor: zoom-in;
		overflow: hidden;
		line-height: 0;
	}

	img {
		display: block;
		inline-size: 100%;
		block-size: auto;
		border-radius: var(--slean-radius-sm);
	}
</style>
```

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/dialog.css';
```

Give the dialog an `id` and a name (`aria-labelledby` on the title, or `aria-label`); a button with `commandfor` and `command="show-modal"` opens it, `command="close"` or a `<form method="dialog">` closes it. Write `<header>` with a heading and a `<p>`, the content, and `<footer>` with the actions: the stylesheet matches them by element. Add `closedby="any"` for a click outside to close it.

`data-slean-side="right|left|top|bottom"` makes it a drawer. `role="alertdialog"`, `closedby="none"`, `aria-describedby` on the question and `autofocus` on the least destructive action make it an alert dialog. The package also exports types, and where invoker commands are missing the fallback is the two calls a command would make:

`types only; registers nothing`

```ts
import type { DialogClosedBy, DialogCommand, DialogSide } from '@svelte-lean/primitives/dialog';

const command: DialogCommand = 'show-modal'; // 'show-modal' | 'close' | 'request-close'
const side: DialogSide = 'right'; // data-slean-side: 'top' | 'right' | 'bottom' | 'left'
const closedby: DialogClosedBy = 'none'; // an alert dialog
```

`fallback where commands are missing`

```svelte
<!-- 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>
```

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<dialog id="…">` | – | yes | An id for the invoker and an accessible name (aria-labelledby or aria-label). data-slean-side="right\|left\|top\|bottom" makes it a drawer; role="alertdialog" with closedby="none" an alert dialog. |
| 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> (a child of the dialog or of its form)` | `header` | styles only | Matched by element; sticky at the top while the dialog scrolls. |
| title | `the first heading of the header` | `title` | styles only | The element aria-labelledby points at. |
| description | `a <p> right after the title` | `description` | styles only | The question of an alert dialog; reference it with aria-describedby. |
| footer | `<footer> (a child of the dialog or of its form)` | `footer` | styles only | The actions, at the inline end; sticky at the bottom. |
| close | `<button type="button" aria-label="Close">` | `close` | no | An icon button drawn at the top end corner; write it last so it is not the first tab stop. command="close" closes the dialog (native). |

## 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); nothing on an alert dialog with closedby="none" |
| `Tab`/`Shift+Tab` | dialog open | Stays inside the dialog because the rest of the document is inert (native) |

## 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`; a drawer slides from its edge. The header, title, description and footer are matched by element (`data-slean-part` names a `<div>` that plays one), header and footer stay in view while the dialog scrolls, and `data-slean-part="close"` draws the corner button. Sizes are `data-size="small"` and `"large"`. The tokens it reads and the states it targets:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-8` | `calc(var(--slean-spacing) * 10)` | inline-size, max-block-size |
| `--slean-dialog-padding` | `var(--slean-space-6)` | padding, inset-block-start, margin, inset-block-end, padding-inline-end, padding-block, padding-right, padding-left, padding-top, padding-inline, padding-bottom |
| `--slean-dialog-gap` | `0px` | gap, margin |
| `--slean-border` | `var(--slean-neutral-6)` | border, border-left, border-right, border-bottom, border-top, border-block-start |
| `--slean-dialog-radius` | `var(--slean-radius-lg)` | border-radius, border-end-start-radius, border-end-end-radius, border-start-start-radius, border-start-end-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-dialog-elevation` | `var(--slean-elevation-4)` | box-shadow |
| `--slean-font-sans` | `Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | font-family |
| `--slean-typography-body1-size` | `1rem` | font-size |
| `--slean-typography-body1-weight` | `400` | font-weight |
| `--slean-typography-body1-line-height` | `1.5` | line-height |
| `--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` | `calc(var(--slean-spacing) * 1)` | gap |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | padding, inset-block-start, margin, padding-inline-end, padding-block |
| `--slean-typography-h6-size` | `1rem` | font-size |
| `--slean-typography-h6-weight` | `600` | font-weight |
| `--slean-typography-h6-line-height` | `1.5` | line-height |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-typography-body2-size` | `0.875rem` | font-size |
| `--slean-typography-body2-weight` | `400` | font-weight |
| `--slean-typography-body2-line-height` | `1.45` | line-height |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap |
| `--slean-control-height-sm` | `2rem` | inline-size, block-size, margin, padding-inline-end |
| `--slean-radius-full` | `9999px` | 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 |
| `--slean-focus-ring` | `var(--slean-focus-ring-width) solid var(--slean-focus-ring-color)` | outline |
| `--slean-focus-ring-offset` | `2px` | outline-offset |
| `--slean-duration-slow` | `240ms` | transition |

State selectors the stylesheet targets, all from the platform or ARIA: `::backdrop`, `:first-child`, `:focus-visible`, `:hover`, `[open]`, `[role="alertdialog"]`.

Variant attributes: `data-size` (`small`, `large`); `data-slean-side` (`right`, `left`, `top`, `bottom`).

## Controlled integration

The dialog's state is its `open` attribute and its `returnValue`. Listen to the native `close` and `cancel` events and call `showModal()` or `close()` to open or close from code. The package mirrors nothing and dispatches no `slean:*` event for a dialog.

`confirm.svelte`

```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 button's value. No open state is mirrored.
	function onclose() {
		outcome = dialog.returnValue;
		dialog.returnValue = '';
	}
</script>

<button type="button" onclick={() => dialog.showModal()}>Open from code</button>
<dialog id="confirm" data-slean="dialog" aria-labelledby="confirm-title" bind:this={dialog} {onclose}>
	…
</dialog>
```

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<dialog>`, `showModal()`, `command`, `commandfor`, `closedby`, `top layer`, `::backdrop`, `position: sticky`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<dialog> + command/commandfor`

Tier 0: the native-only fixture, a production Vite build of a page with button, dialog and popover markup, has no `@svelte-lean/core` or `@svelte-lean/primitives` module, no `data-slean` selector and no `slean:` event string in its output. Nothing is attached at hydration: no listener, no state object, no observer, no scroll lock.

Why this implementation

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 (`commandfor`, `command`) let a button call it declaratively.

A drawer and an alert dialog are the same element: the drawer is a geometry (`data-slean-side`), the alert dialog a role and a close policy the platform already names (`role="alertdialog"`, `closedby="none"`). So one primitive covers the three (ADR 0011 §1.4), with no open-state controller, focus trap, portal, z-index manager or click listener (ADR 0001).

### 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), light dismiss with closedby="any", no dismiss with closedby="none"
- the returnValue of a \<form method="dialog"\> or a close command
- moving focus into the dialog and returning it to the invoker
- dialog and alertdialog semantics and the relationship of the invoker

### Svelte Lean owns

- dialog.css: the surface, sizes, the drawer at four edges, the alert dialog, transitions, sticky header and footer matched by element, the close button
- the contract and the DialogCommand, DialogSide and DialogClosedBy types
- documentation

Accessibility

- A `<dialog>` with an `id` and an accessible name: `aria-labelledby` pointing at the title, or `aria-label`. An alert dialog also has `role="alertdialog"` and `aria-describedby` pointing at the question.
- 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. A drawer keeps a visible close action: a touch screen may have no Escape key.
- Focus: `showModal()` moves focus into the dialog, to the element with `autofocus` when there is one; closing returns focus to the element focused before opening. Write the close button last, so it is not the first tab stop.
- `closedby="none"` on an alert dialog: Escape and a click outside do nothing where the attribute is implemented, so only an explicit answer closes it.

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(). The package ships no polyfill. |
| `closedby` | Chrome 134 and Firefox 141; not in Safari 27 | A click outside does not close a drawer; Escape still cancels an alert dialog (a cancel listener can block it) |
| `@starting-style, transition-behavior: allow-discrete` | Baseline 2024 | The dialog appears and disappears without the transition |
| `overlay (exit transition)` | Chrome 117; not in Firefox or Safari 27 | The dialog closes without the exit transition |

Without JavaScript

With invoker commands supported, every shape opens and closes with no script at all; the playground runs the dialog, drawer and alert dialog assertions with page JavaScript disabled. Without invoker commands and without script it stays closed.

Server rendering and hydration

Static HTML. Modal dialogs open on the client only, so the server never renders an open modal; `open` may be rendered for a non-modal dialog. Ids are authored, never generated, so `aria-labelledby` and `commandfor` are correct in the server HTML.

The delayed-hydration test opens the dialog through its invoker, checks `:modal`, presses Escape and asserts that focus returned, all before any script has loaded. Hydration attaches nothing to a dialog.

Compatibility

`<dialog>`, `showModal()`, the top layer, `::backdrop` and inertness are Baseline widely available. Invoker commands are newly available (Chrome 135, Safari 26, Firefox 144); where they are missing the dialog stays closed, and the package does not polyfill the attribute. `closedby` is in Chrome 134 and Firefox 141, not in Safari 27: there a click outside does not close a drawer and Escape still cancels an alert dialog.

Tests and source

- `apps/playground/tests/primitives/native.spec.ts` Playwright, with page JavaScript enabled and disabled
- `apps/playground/tests/primitives/inputs-overlays.spec.ts` Playwright, with page JavaScript enabled and disabled: the drawer at its edges, the alert dialog closed only by its actions, a long dialog with sticky header, footer and close button, axe
- `apps/playground/tests/primitives/overlay-exit.spec.ts` the layout of every shape holds while it animates closed
- `apps/playground/tests/primitives/a11y.spec.ts` axe on every playground page and on open dialog, popover and menu states
- `fixtures/native-only` consumer build asserting that no behavior runtime ships (invariant A)
- `packages/primitives/scripts/size.mjs` the native-only build that must contain no runtime

- `packages/primitives/src/dialog/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/dialog/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/dialog.css` the optional stylesheet
