Skip to content

Component

dialog

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and dialog.css. No scripts.

Source

Copy both files, or run npx nojsui add dialog.

Source for dialog
<!-- .sk-dialog — canonical demo markup. This is also what the CLI copies.

     The trigger uses Invoker Commands (command/commandfor), so opening a modal
     needs no script. aria-labelledby points at the title: a dialog without an
     accessible name is announced as just "dialog". -->
<button
  type="button"
  class="sk-dialog__trigger"
  command="show-modal"
  commandfor="sk-dialog-demo"
>
  Open dialog
</button>

<dialog id="sk-dialog-demo" class="sk-dialog" aria-labelledby="sk-dialog-demo-title">
  <h2 id="sk-dialog-demo-title" class="sk-dialog__title">Delete this project?</h2>

  <p class="sk-dialog__body">
    This removes the project and everything in it. Focus is trapped in here and
    returns to the button on close — the browser does that, not a script.
  </p>

  <div class="sk-dialog__actions">
    <button
      type="button"
      class="sk-dialog__button"
      command="close"
      commandfor="sk-dialog-demo"
    >
      Cancel
    </button>
    <button
      type="button"
      class="sk-dialog__button"
      data-tone="danger"
      command="close"
      commandfor="sk-dialog-demo"
    >
      Delete
    </button>
  </div>
</dialog>
/* .sk-dialog — a modal dialog with no JavaScript.
   <dialog> supplies modality itself: the background goes inert, focus is
   confined to the dialog, Esc closes, and focus returns to the invoker.
   Invoker Commands (command/commandfor) open and close it without script.
   This file only dresses that and animates it.

   Specs:
   - <dialog> ............... https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
   - invoker commands ....... https://html.spec.whatwg.org/multipage/form-elements.html#attr-button-command
   - ::backdrop ............. https://drafts.csswg.org/css-position-4/#backdrop
   - @starting-style ........ https://drafts.csswg.org/css-transitions-2/#defining-before-change-style
   - transition-behavior .... https://drafts.csswg.org/css-transitions-2/#transition-behavior-property */

/* Public theme knobs (ADR 0011). Read, never declared, so the nearest
   declaration above the component wins.

   This component has no single root: the <dialog> is in the top layer and the
   trigger is its sibling, so the aliases are declared on both. The trigger and
   the dialog's own action buttons are the same control visually and share one
   set of knobs — splitting them would mean two tables to keep in step for a
   difference the default theme does not make. */
.sk-dialog,
.sk-dialog__trigger {
  --_bg: var(--sk-dialog-bg, var(--sk-color-surface-raised));
  --_fg: var(--sk-dialog-fg, var(--sk-color-text));
  --_border: var(--sk-dialog-border-color, var(--sk-color-border));
  --_radius: var(--sk-dialog-radius, var(--sk-radius-lg));
  --_shadow: var(--sk-dialog-shadow, var(--sk-shadow-lg));
  --_max-inline-size: var(--sk-dialog-max-inline-size, 28rem);
  --_backdrop-blur: var(--sk-dialog-backdrop-blur, 4px);
  --_button-bg: var(--sk-dialog-button-bg, var(--sk-color-surface));
  --_button-bg-hover: var(--sk-dialog-button-bg-hover, var(--sk-color-surface-raised));
  --_button-fg: var(--sk-dialog-button-fg, var(--sk-color-text));
  --_button-border: var(--sk-dialog-button-border-color, var(--sk-color-border-strong));
  --_button-radius: var(--sk-dialog-button-radius, var(--sk-radius-md));
  --_danger-bg: var(--sk-dialog-danger-bg, var(--sk-color-danger));
  --_danger-fg: var(--sk-dialog-danger-fg, var(--sk-color-on-danger));
}

.sk-dialog__trigger {
  border: var(--sk-border-width) solid var(--_button-border);
  border-radius: var(--_button-radius);
  background: var(--_button-bg);
  color: var(--_button-fg);
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-md);
  font-weight: var(--sk-weight-medium);
  min-block-size: var(--sk-size-tap-target);
}

.sk-dialog__trigger:hover {
  background: var(--_button-bg-hover);
}

/* The dialog itself. margin: auto is what centres a top-layer dialog; the UA
   already does this, and it is restated because the padding reset above it
   would otherwise be ambiguous to a reader. */
.sk-dialog {
  margin: auto;
  inline-size: min(var(--_max-inline-size), calc(100vw - var(--sk-space-xl)));
  max-block-size: calc(100dvb - var(--sk-space-xl));
  padding-block: var(--sk-space-lg);
  padding-inline: var(--sk-space-lg);
  border: var(--sk-border-width) solid var(--_border);
  border-radius: var(--_radius);
  background: var(--_bg);
  color: var(--_fg);
  box-shadow: var(--_shadow);
}

.sk-dialog__title {
  margin-block-end: var(--sk-space-xs);
  font-size: var(--sk-text-lg);
  letter-spacing: var(--sk-tracking-tight);
}

.sk-dialog__body {
  margin-block-end: var(--sk-space-lg);
  color: var(--sk-color-text-muted);
  font-size: var(--sk-text-sm);
}

.sk-dialog__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: var(--sk-space-xs);
}

.sk-dialog__button {
  border: var(--sk-border-width) solid var(--_button-border);
  border-radius: var(--_button-radius);
  background: var(--_button-bg);
  color: var(--_button-fg);
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-md);
  font-weight: var(--sk-weight-medium);
  min-block-size: var(--sk-size-tap-target);
}

.sk-dialog__button:hover {
  background: var(--_button-bg-hover);
}

.sk-dialog__button[data-tone="danger"] {
  border-color: transparent;
  background: var(--_danger-bg);
  color: var(--_danger-fg);
}

.sk-dialog__button[data-tone="danger"]:hover {
  /* Darken by mixing toward the text colour rather than hardcoding a second
     danger token: the pair stays correct under any consumer theme. */
  background: color-mix(in oklch, var(--_danger-bg) 85%, var(--_fg));
}

/* ---------------------------------------------------------------------------
   Backdrop
   base.css already tokenises ::backdrop; the blur is the enhancement, and it
   is skipped entirely for anyone who has asked for less transparency.
--------------------------------------------------------------------------- */

@supports (backdrop-filter: blur(1px)) {
  @media (prefers-reduced-transparency: no-preference) {
    .sk-dialog::backdrop {
      backdrop-filter: blur(var(--_backdrop-blur));
    }
  }
}

/* ---------------------------------------------------------------------------
   Motion

   All opt-in (CLAUDE.md rule 4). Without it the dialog appears and disappears,
   which is complete — base.css also clamps durations globally under `reduce`.

   There is no view transition here. A same-document view transition has to be
   started from script (document.startViewTransition), and @view-transition only
   covers cross-document navigation, so a script-free dialog cannot use one.
   The README says so.
--------------------------------------------------------------------------- */

@media (prefers-reduced-motion: no-preference) {
  .sk-dialog {
    opacity: 0;
    scale: 0.97;
    transition:
      opacity var(--sk-motion-fast) var(--sk-ease-out),
      scale var(--sk-motion-fast) var(--sk-ease-out);
  }

  .sk-dialog[open] {
    opacity: 1;
    scale: 1;
  }

  @starting-style {
    .sk-dialog[open] {
      opacity: 0;
      scale: 0.97;
    }
  }

  .sk-dialog::backdrop {
    opacity: 0;
    transition: opacity var(--sk-motion-fast) var(--sk-ease-out);
  }

  .sk-dialog[open]::backdrop {
    opacity: 1;
  }

  @starting-style {
    .sk-dialog[open]::backdrop {
      opacity: 0;
    }
  }

  /* display and overlay are discrete: without allow-discrete they flip in one
     step and the dialog is gone before it can fade. Exit only. */
  @supports (transition-behavior: allow-discrete) {
    .sk-dialog {
      transition:
        opacity var(--sk-motion-fast) var(--sk-ease-out),
        scale var(--sk-motion-fast) var(--sk-ease-out),
        display var(--sk-motion-fast) allow-discrete,
        overlay var(--sk-motion-fast) allow-discrete;
    }

    .sk-dialog::backdrop {
      transition:
        opacity var(--sk-motion-fast) var(--sk-ease-out),
        display var(--sk-motion-fast) allow-discrete,
        overlay var(--sk-motion-fast) allow-discrete;
    }
  }
}

Browser support

Baseline newly available

Shipped in every major engine, but only recently — older versions need the fallback.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
<dialog>widely37799815.4379815.4
Invoker commandsnewly13513514426.213514426.2
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
@starting-stylenewly11711712917.511712917.5
transition-behaviornewly11711712917.411712917.4
backdrop-filternewly7679103187610318

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).

PropertyDefaultControls
--sk-dialog-bg--sk-color-surface-raisedDialog background
--sk-dialog-fg--sk-color-textDialog text
--sk-dialog-border-color--sk-color-borderDialog border
--sk-dialog-radius--sk-radius-lgDialog corner radius
--sk-dialog-shadow--sk-shadow-lgDialog elevation
--sk-dialog-max-inline-size28remWidest the dialog grows before the viewport clamp takes over
--sk-dialog-backdrop-blur4pxBackdrop blur radius, where the engine supports it
--sk-dialog-button-bg--sk-color-surfaceButton background
--sk-dialog-button-bg-hover--sk-color-surface-raisedButton background on hover
--sk-dialog-button-fg--sk-color-textButton text
--sk-dialog-button-border-color--sk-color-border-strongButton border
--sk-dialog-button-radius--sk-radius-mdButton corner radius
--sk-dialog-danger-bg--sk-color-dangerBackground of a data-tone="danger" button
--sk-dialog-danger-fg--sk-color-on-dangerText 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

KeyBehaviour
Enter / Space on the triggerOpens the dialog; focus moves to the first focusable element inside
TabMoves through the dialog’s contents; the page behind is inert and cannot be reached
EscCloses 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-labelledby pointing 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-modal is not set, deliberately. A dialog opened with showModal() — which is what command="show-modal" does — is already exposed as modal. Adding aria-modal="true" on top is redundant and, if the dialog is ever opened non-modally with show(), actively wrong.

Degradation

Baseline column from support.json — regenerate with pnpm support.

Feature usedBaseline statusBehavior without it
<dialog>widely
Invoker CommandsnewlyThe trigger does nothing. See below.
@starting-stylenewlyNo entry animation; the dialog appears at full opacity.
transition-behavior: allow-discretenewlyNo exit animation; the dialog disappears at once.
backdrop-filternewlyBackdrop 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.