Skip to content

Component

toast

Demo

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

Source

Copy both files, or run npx nojsui add toast.

Source for toast
<!-- .sk-toast — a status message that dismisses itself, with no JavaScript.

     The region is popover="manual": it is painted in the top layer and has no
     light dismiss, so a stray click elsewhere cannot swallow a message the user
     has not read yet.

     data-sk-motion="essential" opts this element out of base.css's global
     reduced-motion clamp. The auto-dismiss *is* an animation; clamped to
     0.01ms the message would be hidden before it could be read. See ADR 0008
     and the README. -->
<div class="sk-toast__triggers">
  <button
    type="button"
    class="sk-toast__trigger"
    command="show-popover"
    commandfor="sk-toast-saved"
  >
    Save
  </button>

  <button
    type="button"
    class="sk-toast__trigger"
    command="hide-popover"
    commandfor="sk-toast-saved"
  >
    Reset
  </button>
</div>

<div
  id="sk-toast-saved"
  class="sk-toast"
  popover="manual"
  role="status"
  data-sk-motion="essential"
>
  <p class="sk-toast__text">Saved to your library</p>

  <button
    type="button"
    class="sk-toast__close"
    command="hide-popover"
    commandfor="sk-toast-saved"
    aria-label="Dismiss"
  >
    <span aria-hidden="true">&times;</span>
  </button>
</div>
/* .sk-toast — a status message that appears, is announced, and removes itself.

   The auto-dismiss is a CSS animation that ends on visibility: hidden and is
   held there by animation-fill-mode: forwards. Verified in Chromium, WebKit and
   Gecko: once it finishes, the message is gone from the accessibility tree, not
   merely transparent.

   Specs:
   - popover=manual ......... https://html.spec.whatwg.org/multipage/popover.html#attr-popover-manual
   - invoker commands ....... https://html.spec.whatwg.org/multipage/form-elements.html#attr-button-command
   - role=status ............ https://w3c.github.io/aria/#status
   - animation-fill-mode .... https://drafts.csswg.org/css-animations/#animation-fill-mode */

/* Public theme knobs (ADR 0011). Read, never declared, so the nearest
   declaration above the component wins. The region is a top-layer popover and
   the triggers sit beside it, so the aliases are declared on both. */
.sk-toast,
.sk-toast__triggers {
  --_life: var(--sk-toast-life, 5s);
  --_bg: var(--sk-toast-bg, var(--sk-color-surface-raised));
  --_fg: var(--sk-toast-fg, var(--sk-color-text));
  --_border: var(--sk-toast-border-color, var(--sk-color-border));
  --_radius: var(--sk-toast-radius, var(--sk-radius-lg));
  --_shadow: var(--sk-toast-shadow, var(--sk-shadow-lg));
  --_inline-size: var(--sk-toast-inline-size, 24rem);
  --_close-fg: var(--sk-toast-close-fg, var(--sk-color-text-muted));
  --_close-bg-hover: var(--sk-toast-close-bg-hover, var(--sk-color-surface));
  --_button-bg: var(--sk-toast-button-bg, var(--sk-color-surface));
  --_button-bg-hover: var(--sk-toast-button-bg-hover, var(--sk-color-surface-raised));
  --_button-fg: var(--sk-toast-button-fg, var(--sk-color-text));
  --_button-border: var(--sk-toast-button-border-color, var(--sk-color-border-strong));
  --_button-radius: var(--sk-toast-button-radius, var(--sk-radius-md));
}

.sk-toast__triggers {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sk-space-xs);
  padding-block: var(--sk-space-md);
  padding-inline: var(--sk-space-md);
}

.sk-toast__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-toast__trigger:hover {
  background: var(--_button-bg-hover);
}

/* The region. A popover's containing block is the viewport, so pinning it to a
   corner is margin plus logical insets — the same mechanism as the drawer. */
.sk-toast {
  margin: 0;
  inset-block: auto var(--sk-space-lg);
  inset-inline: auto var(--sk-space-lg);
  align-items: center;
  gap: var(--sk-space-sm);
  inline-size: min(var(--_inline-size), calc(100vi - var(--sk-space-xl)));
  padding-block: var(--sk-space-sm);
  padding-inline: var(--sk-space-md);
  border: var(--sk-border-width) solid var(--_border);
  border-radius: var(--_radius);
  background: var(--_bg);
  color: var(--_fg);
  box-shadow: var(--_shadow);
}

.sk-toast__text {
  margin: 0;
  font-size: var(--sk-text-sm);
}

.sk-toast__close {
  margin-inline-start: auto;
  border: 0;
  border-radius: var(--sk-radius-full);
  background: none;
  color: var(--_close-fg);
  font-size: var(--sk-text-lg);
  line-height: 1;
  min-inline-size: var(--sk-size-tap-target);
  min-block-size: var(--sk-size-tap-target);
}

.sk-toast__close:hover {
  background: var(--_close-bg-hover);
  color: var(--_fg);
}

/* ---------------------------------------------------------------------------
   Auto-dismiss

   This is the component's function, not its decoration — which is why the
   element carries data-sk-motion="essential" and why this animation lives
   outside a prefers-reduced-motion guard. ADR 0008 has the reasoning.

   Under `reduce` the slide is dropped and only the fade remains; the timing is
   identical, so the message is readable for exactly as long either way.
--------------------------------------------------------------------------- */

.sk-toast:popover-open {
  animation: sk-toast-life var(--_life) var(--sk-ease-out) forwards;
}

@keyframes sk-toast-life {
  0% {
    opacity: 0;
    visibility: visible;
    translate: 0 var(--sk-space-sm);
  }

  6%,
  88% {
    opacity: 1;
    visibility: visible;
    translate: 0 0;
  }

  100% {
    opacity: 0;

    /* Discrete, and the reason the message leaves the accessibility tree
       rather than lingering invisibly. */
    visibility: hidden;
    translate: 0 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  @keyframes sk-toast-life {
    0% {
      opacity: 0;
      visibility: visible;
    }

    6%,
    88% {
      opacity: 1;
      visibility: visible;
    }

    100% {
      opacity: 0;
      visibility: hidden;
    }
  }
}

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
Popovernewly1161161251711612518.3
Invoker commandsnewly13513514426.213514426.2

Usage

<button type="button" command="show-popover" commandfor="saved">Save</button>

<div id="saved" class="sk-toast" popover="manual" role="status" data-sk-motion="essential">
  <p class="sk-toast__text">Saved to your library</p>
  <button type="button" class="sk-toast__close" command="hide-popover" commandfor="saved" aria-label="Dismiss">
    <span aria-hidden="true">&times;</span>
  </button>
</div>

Set how long it stays with --sk-toast-life (default 5s) — see Theming below.

What this component is, and is not

A toast is triggered by something happening — a save completing, a request failing. CSS cannot observe that, so this component does not pretend to be a notification system. What it is:

  • the region, its placement and its styling
  • a CSS-only auto-dismiss that removes the message from the screen and from the accessibility tree on a timer
  • a manual dismiss that needs no script

In a real application the trigger is your own code calling showPopover(), or an invoker button as in the demo. Everything after the trigger is script-free.

Use alert instead when the message must stay — a form validation error, a success panel that replaces a form, anything the user has to act on. It renders in normal flow, has no timer, and can be shown as many times as you like.

The auto-dismiss

An animation ends on visibility: hidden and animation-fill-mode: forwards holds it there. visibility is a discrete property, so it flips at the end of the timeline rather than fading — which is exactly what makes the message leave rather than linger invisibly.

Verified in Chromium, WebKit and Gecko: while the toast is up, the region appears in the accessibility tree as status: Saved to your library; after the timeline finishes, it is gone from the tree entirely. Not transparent-but-present — absent.

Why this component opts out of the reduced-motion clamp

data-sk-motion="essential" exempts the region from base.css’s global reduced-motion clamp. This is the only component in the kit that does it, and ADR 0008 is the justification.

Without the exemption, prefers-reduced-motion: reduce compresses the auto-dismiss timeline to 0.01ms. Measured before the fix: the toast was visibility: hidden 120ms after being shown — the message was never readable at all. The floor meant to protect the user from motion was destroying the component’s function instead. WCAG 2.3.3 exempts motion essential to functionality for the same reason.

Reduced motion still changes the experience. Under reduce the toast drops its slide-in and fades only. The timing is identical, so the message is readable for exactly as long either way — which is the point.

Known limitation: showing it twice

CSS can end the animation, but it cannot close a popover. After an auto-dismiss the region is invisible and out of the accessibility tree, but still technically open — so a second command="show-popover" does nothing until something hides it first.

The dismiss button (command="hide-popover") resets it, which is why the demo has a Reset button next to Save. In an application this rarely comes up: whatever triggers your toasts will hide the region before showing it again.

Theming

Set any of these anywhere above the component — :root, a section wrapper, or one instance. toast.css only ever reads them, so the nearest declaration wins (ADR 0011).

PropertyDefaultControls
--sk-toast-life5sHow long the message stays before it removes itself
--sk-toast-bg--sk-color-surface-raisedMessage background
--sk-toast-fg--sk-color-textMessage text
--sk-toast-border-color--sk-color-borderMessage border
--sk-toast-radius--sk-radius-lgMessage corner radius
--sk-toast-shadow--sk-shadow-lgMessage elevation
--sk-toast-inline-size24remMessage width before the viewport clamp
--sk-toast-close-fg--sk-color-text-mutedClose button glyph
--sk-toast-close-bg-hover--sk-color-surfaceClose button background on hover
--sk-toast-button-bg--sk-color-surfaceTrigger background
--sk-toast-button-bg-hover--sk-color-surface-raisedTrigger background on hover
--sk-toast-button-fg--sk-color-textTrigger text
--sk-toast-button-border-color--sk-color-border-strongTrigger border
--sk-toast-button-radius--sk-radius-mdTrigger corner radius
:root { --sk-toast-life: 8s; }

--sk-toast-life is the one knob here that changes behaviour rather than appearance, and it is deliberately exposed: how long a message is readable is a content decision, not ours. Give people enough time to read it — WCAG 2.2 SC 2.2.1 applies, and the close button is what makes the timing adjustable at all.

Keyboard contract

KeyBehaviour
TabReaches the dismiss button while the toast is up
Enter / Space on dismissHides it immediately

The toast never takes focus — verified in all three engines. A message that stole focus would interrupt whatever the user was doing, which is precisely what a toast is supposed to avoid.

Verified manually in VoiceOver and NVDA: not yet — do this before the component is marked done. role="status" announcement timing is the thing to check: it is polite, so it should be read at the next opportunity rather than interrupting.

Accessibility notes

  • role="status" makes the region a polite live region: announced without interrupting. Do not change it to role="alert" unless the message is genuinely urgent — alert interrupts.
  • popover="manual", not auto. Light dismiss would let an unrelated click swallow a message the user has not read.
  • The dismiss button has a real label. &times; is decorative and aria-hidden; the button carries aria-label="Dismiss".
  • Do not put essential information only in a toast. It removes itself on a timer by design. Anything the user may need again belongs somewhere permanent.

Degradation

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

Feature usedBaseline statusBehavior without it
Popover APInewlyThe region renders inline in normal flow instead of the top layer, as a plain block rather than the flex row. It still announces and still auto-dismisses; it just is not pinned to the corner.
Invoker CommandsnewlyThe demo’s buttons do nothing. An application triggering the toast from its own code is unaffected — see docs/polyfills.md for the opt-in shim.

The auto-dismiss itself needs only CSS animations and visibility, which are Baseline widely available — so the part that is hardest to do without script is also the part with the fewest support caveats.