Component
dialog
Demo
tokens.css,
base.css and dialog.css. No scripts.
Source
Copy both files, or run npx nojsui add dialog.
Browser support
Shipped in every major engine, but only recently — older versions need the fallback.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| <dialog> | widely | 37 | 79 | 98 | 15.4 | 37 | 98 | 15.4 |
| Invoker commands | newly | 135 | 135 | 144 | 26.2 | 135 | 144 | 26.2 |
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| @starting-style | newly | 117 | 117 | 129 | 17.5 | 117 | 129 | 17.5 |
| transition-behavior | newly | 117 | 117 | 129 | 17.4 | 117 | 129 | 17.4 |
| backdrop-filter | newly | 76 | 79 | 103 | 18 | 76 | 103 | 18 |
Usage
A trigger and a <dialog>. The trigger opens it with command="show-modal";
buttons inside close it with command="close". No script anywhere.
<button type="button" command="show-modal" commandfor="confirm">Delete…</button>
<dialog id="confirm" class="sk-dialog" aria-labelledby="confirm-title">
<h2 id="confirm-title" class="sk-dialog__title">Delete this project?</h2>
<p class="sk-dialog__body">…</p>
<div class="sk-dialog__actions">
<button type="button" class="sk-dialog__button" command="close" commandfor="confirm">Cancel</button>
<button type="button" class="sk-dialog__button" data-tone="danger" command="close" commandfor="confirm">Delete</button>
</div>
</dialog>
aria-labelledby is not optional. Without it the dialog is announced as just
“dialog”, and the user has to go looking for what it is about.
How it works
<dialog> supplies modality; Invoker Commands supply the trigger. Opened
as a modal, the browser makes the rest of the page inert, confines focus to the
dialog, closes on Esc, and returns focus to the invoker. command/
commandfor replace the one line of showModal() that used to be the reason a
modal needed script.
Nothing here re-implements focus. No tabindex juggling, no sentinel
elements, no CSS tricks (CLAUDE.md rule 9). Measured across Chromium, WebKit
and Gecko: with the dialog open, calling focus() on a background button does
not move focus in any of them.
There is no view transition, and there cannot be one. A same-document view
transition has to be started from document.startViewTransition(), and
@view-transition only applies to cross-document navigation — so a script-free
dialog cannot use one. The open/close animation is a plain transition with
@starting-style for entry and transition-behavior: allow-discrete for exit,
which gets to the same place for this case.
Theming
Set any of these anywhere above the component — :root, a section wrapper, or
one instance. dialog.css only ever reads them, so the nearest declaration wins
(ADR 0011).
| Property | Default | Controls |
|---|---|---|
--sk-dialog-bg | --sk-color-surface-raised | Dialog background |
--sk-dialog-fg | --sk-color-text | Dialog text |
--sk-dialog-border-color | --sk-color-border | Dialog border |
--sk-dialog-radius | --sk-radius-lg | Dialog corner radius |
--sk-dialog-shadow | --sk-shadow-lg | Dialog elevation |
--sk-dialog-max-inline-size | 28rem | Widest the dialog grows before the viewport clamp takes over |
--sk-dialog-backdrop-blur | 4px | Backdrop blur radius, where the engine supports it |
--sk-dialog-button-bg | --sk-color-surface | Button background |
--sk-dialog-button-bg-hover | --sk-color-surface-raised | Button background on hover |
--sk-dialog-button-fg | --sk-color-text | Button text |
--sk-dialog-button-border-color | --sk-color-border-strong | Button border |
--sk-dialog-button-radius | --sk-radius-md | Button corner radius |
--sk-dialog-danger-bg | --sk-color-danger | Background of a data-tone="danger" button |
--sk-dialog-danger-fg | --sk-color-on-danger | Text of a danger button |
:root { --sk-dialog-radius: 0; }
.compact-ui { --sk-dialog-max-inline-size: 22rem; }
The --sk-dialog-button-* knobs style both the trigger and the dialog’s own
action buttons: they are the same control, and one table is easier to keep
honest than two.
The danger button darkens on hover by mixing --sk-dialog-danger-bg toward
--sk-dialog-fg, so the hover state follows whatever you set without a second
knob. Modality, focus containment and Esc come from <dialog> and are not
themeable.
Keyboard contract
| Key | Behaviour |
|---|---|
Enter / Space on the trigger | Opens the dialog; focus moves to the first focusable element inside |
Tab | Moves through the dialog’s contents; the page behind is inert and cannot be reached |
Esc | Closes the dialog and returns focus to the trigger |
Verified in all three engines: opening, Esc, focus return to the invoker, and
inertness of the background.
Tab wrapping at the last element is engine-dependent. In Chromium and
WebKit, Tab from the last control cycles back to the first. In Gecko, our
automated runs saw focus stay on the last control instead of wrapping. Focus
never escapes the dialog in any engine — that part is solid — but do not
promise users a perfect loop. Confirm during the manual pass.
Safari’s tab order excludes buttons and links unless the OS “Full Keyboard Access” setting is on. That is a Safari-wide default, not something this component causes, but it means a dialog whose only controls are buttons has a very short tab order there.
Verified manually in VoiceOver and NVDA: not yet — do this before the component is marked done.
Accessibility notes
- The dialog needs an accessible name. Use
aria-labelledbypointing at the title, as in the markup above. - Destructive actions get
data-tone="danger", which colours the button — but colour is never the only signal. The label says “Delete”. - The backdrop blur is skipped under
prefers-reduced-transparency. aria-modalis not set, deliberately. A dialog opened withshowModal()— which is whatcommand="show-modal"does — is already exposed as modal. Addingaria-modal="true"on top is redundant and, if the dialog is ever opened non-modally withshow(), actively wrong.
Degradation
Baseline column from support.json — regenerate with pnpm support.
| Feature used | Baseline status | Behavior without it |
|---|---|---|
<dialog> | widely | — |
| Invoker Commands | newly | The trigger does nothing. See below. |
@starting-style | newly | No entry animation; the dialog appears at full opacity. |
transition-behavior: allow-discrete | newly | No exit animation; the dialog disappears at once. |
backdrop-filter | newly | Backdrop dims but does not blur. |
Be honest about the floor. Without Invoker Commands the trigger is an inert
button: nothing opens, and no CSS can rescue that. This component’s usable floor
is therefore Invoker Commands, not <dialog> — Chrome 135, Firefox 144,
Safari 26.2.
If you need to support older engines, there is a small feature-tested shim in
docs/polyfills.md — an opt-in enhancement
for consumers, deliberately not part of this component. Components here ship
zero JavaScript, and quietly adding a script would make that claim false.