Primitives Overlays
Alert dialog
A modal dialog with the alertdialog role that asks for a decision and closes only through its own actions. The browser owns the top layer, page inertness, focus and the returned value; the package adds a stylesheet, types and a contract. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - Platform features
<dialog>,role="alertdialog",closedby="none",command,commandfor,autofocus,returnValue- Shared listeners
- none
- Per-instance listeners
- none
- Lazy state
- none
- Native base
<dialog role="alertdialog" closedby="none"> + command/commandfor
On this page
Example
<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><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/alert-dialog.css';
</script>
<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>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/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@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
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <dialog id="…" role="alertdialog" closedby="none"> | – | yes | Needs an id, aria-labelledby (title) and aria-describedby (description). Always opened as a modal. |
| invoker | <button type="button" commandfor="<id>" command="show-modal"> | – | yes | Opens the dialog as a modal. |
| header | <header> | header | styles only | Groups the title and description. |
| title | <h2> | title | styles only | The question; the element aria-labelledby points at. |
| description | <p> | description | styles only | The consequence; the element aria-describedby points at. |
| footer | <footer> or <form method="dialog"> | footer | styles only | The 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"witharia-labelledbyon the title andaria-describedbyon the description, so the question and its consequence are announced when the dialog opens.- Focus moves to the
autofocusaction 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
closedbyis implemented; the actions are the way out, and each is a labelled button.
Keyboard
| Key | When | Result |
|---|---|---|
| Enter/Space | focus on the invoker | Opens the dialog with focus on the autofocus action (native) |
| Tab/Shift+Tab | dialog open | Moves between the actions; the rest of the document is inert (native) |
| Enter/Space | focus on an action | Closes the dialog with the action’s value as returnValue (native) |
| Escape | dialog open | Nothing where closedby is implemented; elsewhere cancels without changing returnValue (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(). The package ships no polyfill. |
closedby | Chrome 134 and Firefox 141; not in Safari 27 | Escape cancels the dialog; returnValue keeps its previous value |
overlay (exit transition) | Chrome 117; not in Safari 27 | The 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:
| 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-2 | 0.5rem | gap |
--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-leading | 1.5 | line-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.
<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
apps/playground/tests/primitives/inputs-overlays.spec.tsPlaywright, with page JavaScript enabled and disabled, and axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/alert-dialog/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/alert-dialog/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/alert-dialog.cssthe optional stylesheet