Toast

A non-blocking status message. Lives in a corner region, auto-dismisses, and pauses on hover.

Setup

Mount one .toast-region in your app shell so the helper has somewhere to render. Tag it with data-stisla-toast-region="default" and Stisla.toast(...) will land there when no region opt is passed.

<!-- Drop once in your layout, near the end of <body>. -->
<div class="toast-region toast-region--top-center"
     data-stisla-toast-region="default"
     role="region"
     aria-label="Notifications"></div>

If you skip the snippet above, Stisla.toast(...) creates the same region itself the first time it runs and appends it to <body>. That works as a fallback, but mounting it yourself keeps the region where you expect it in the DOM and lets you tag it with your own ARIA label.

For apps that want toasts in more than one corner (say, system errors at top-end and chat pings at bottom-start), mount one region per corner, give each a unique data-stisla-toast-region name, then target it from JS with the region opt.

<div class="toast-region toast-region--top-end"
     data-stisla-toast-region="alerts"
     role="region" aria-label="System alerts"></div>

<div class="toast-region toast-region--bottom-start"
     data-stisla-toast-region="chat"
     role="region" aria-label="Chat messages"></div>
Stisla.toast({
  title: "Build failed",
  variant: "danger",
  region: '[data-stisla-toast-region="alerts"]',
});

Stisla.toast({
  title: "Maya: hey there",
  region: '[data-stisla-toast-region="chat"]',
});

How it works

Two pieces, two lifetimes. The .toast-region is position: fixed, carries the corner placement and the live-region ARIA wrapper, and persists for the page. Individual .toast nodes come and go inside it; they're created by markup or by the JS helper, opened, paused on hover, then dismissed when the user clicks close or the auto-hide timer fires.

State lives on the toast root as data-state="open|closed". Closed toasts are display: none so they don't reserve layout space and a stack of pending markup-first toasts doesn't push the next-to-open one down the page. Hovering or focusing an open toast pauses its auto-hide timer (per WCAG 2.2.1) and leaving resumes it. Re-triggering an already-open toast restarts the timer rather than stacking a duplicate.

Two ways to create a toast:

  • Markup-first. Author a .toast inside the region with data-state="closed", then open it with a data-stisla-toast-trigger="<id>" button. The toast stays in the DOM and can be re-triggered. See the Trigger section below.
  • Imperative. Call Stisla.toast({ title, description, variant }). The helper builds the node, mounts it, opens it, and removes it from the DOM after close. See the JS helper section below.

Basic

A toast carries a leading icon, a title, and an optional body. The icon column is required so the toast keeps a consistent visual anchor. Use a Lucide icon, an .icon-box, or an image. Drop one inside a .toast-region to position it; the region holds the stack and the corner placement. For static demos like this one, the toast sits inline with data-state="open" so it stays visible.

Workspace saved
Your changes to billing details have been written and synced.
<div class="toast" data-state="open" role="status" aria-live="polite" aria-atomic="true">
  <div class="toast__icon"><i data-lucide="bell"></i></div>
  <div class="toast__content">
    <div class="toast__header">Workspace saved</div>
    <div class="toast__body">Your changes to billing details have been written and synced.</div>
  </div>
  <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close">
    <i data-lucide="x"></i>
  </button>
</div>

Anatomy

The row has three columns. A .toast__icon leading slot, a .toast__content vertical stack, and a .toast__close chip. The content stack carries .toast__header with an optional .toast__timestamp, .toast__body for the description, and .toast__action for inline buttons. The stack owns its own gap so spacing stays consistent regardless of which parts are present.

Two-factor enabled just now
Logins to this workspace now require a code from your authenticator app.
<div class="toast" data-state="open" role="status" aria-live="polite" aria-atomic="true">
  <div class="toast__icon"><i data-lucide="shield-check"></i></div>
  <div class="toast__content">
    <div class="toast__header">
      Two-factor enabled
      <span class="toast__timestamp">just now</span>
    </div>
    <div class="toast__body">Logins to this workspace now require a code from your authenticator app.</div>
    <div class="toast__action">
      <button type="button" class="btn btn--sm btn--ghost btn--neutral btn--flush-start">Dismiss</button>
      <button type="button" class="btn btn--sm btn--ghost btn--neutral">Manage devices</button>
    </div>
  </div>
  <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close">
    <i data-lucide="x"></i>
  </button>
</div>

Intent variants

Five modifiers shift the leading-icon color. .toast--primary, .toast--success, .toast--warning, .toast--danger, and .toast--info. The surface stays neutral so stacked toasts of mixed intent read as one family. For danger toasts, bump the live region to role="alert" and aria-live="assertive" so screen readers announce them immediately.

Invoice sent
We emailed the May invoice to billing@acme.co.
Storage almost full
You're at 94% of the workspace quota. Archive old exports to free space.
New version available
Refresh to pick up the latest dashboard build.
Reminder set
We'll ping you 10 minutes before the meeting.
<div class="d-flex flex-column gap-3">
  <div class="toast toast--success" data-state="open" role="status" aria-live="polite" aria-atomic="true">
    <div class="toast__icon"><i data-lucide="check-circle-2"></i></div>
    <div class="toast__content">
      <div class="toast__header">Invoice sent</div>
      <div class="toast__body">We emailed the May invoice to billing@acme.co.</div>
    </div>
    <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close"><i data-lucide="x"></i></button>
  </div>
  <div class="toast toast--danger" data-state="open" role="alert" aria-live="assertive" aria-atomic="true">
    <div class="toast__icon"><i data-lucide="x-circle"></i></div>
    <div class="toast__content">
      <div class="toast__header">Couldn't upload</div>
      <div class="toast__body">Network dropped halfway through. Reconnect and try again.</div>
    </div>
    <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close"><i data-lucide="x"></i></button>
  </div>
  <div class="toast toast--warning" data-state="open" role="status" aria-live="polite" aria-atomic="true">
    <div class="toast__icon"><i data-lucide="triangle-alert"></i></div>
    <div class="toast__content">
      <div class="toast__header">Storage almost full</div>
      <div class="toast__body">You're at 94% of the workspace quota. Archive old exports to free space.</div>
    </div>
    <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close"><i data-lucide="x"></i></button>
  </div>
  <div class="toast toast--info" data-state="open" role="status" aria-live="polite" aria-atomic="true">
    <div class="toast__icon"><i data-lucide="info"></i></div>
    <div class="toast__content">
      <div class="toast__header">New version available</div>
      <div class="toast__body">Refresh to pick up the latest dashboard build.</div>
    </div>
    <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close"><i data-lucide="x"></i></button>
  </div>
  <div class="toast toast--primary" data-state="open" role="status" aria-live="polite" aria-atomic="true">
    <div class="toast__icon"><i data-lucide="bell"></i></div>
    <div class="toast__content">
      <div class="toast__header">Reminder set</div>
      <div class="toast__body">We'll ping you 10 minutes before the meeting.</div>
    </div>
    <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close"><i data-lucide="x"></i></button>
  </div>
</div>

Leading slot

The icon column takes any leading visual. Drop in an avatar, a glyph at full tone, an .icon-box, a spinner, or an emoji. Override --toast-icon-size to grow the slot for heavier visuals, and --toast-icon-color to recolor monochrome content.

MS
Maya Singh 10 mins ago
Shared "Q3 roadmap" with you. You have edit access to the document.
Review requested
Maya tagged you on "fix(auth): rotate session keys".
Deployment succeeded
v2.4.1 is live on staging. Production rollout queued for 4 PM.
Uploading 3 files 62%
Don't close this window until upload completes.
🎉
First sale!
Pro plan subscription from sarah@acme.co. Cha-ching.
<div class="d-flex flex-column gap-3">

  
  <div class="toast" data-state="open" role="status" aria-live="polite" aria-atomic="true" style="--toast-icon-size: 2.25rem;">
    <div class="toast__icon fs-1 fw-semibold" style="background-color: #6366f1; color: white; border-radius: 50%;">MS</div>
    <div class="toast__content">
      <div class="toast__header">
        Maya Singh
        <span class="toast__timestamp">10 mins ago</span>
      </div>
      <div class="toast__body">Shared "Q3 roadmap" with you. You have edit access to the document.</div>
      <div class="toast__action">
        <button type="button" class="btn btn--sm btn--ghost btn--neutral btn--flush-start">Dismiss</button>
        <button type="button" class="btn btn--sm btn--ghost btn--primary">Open file</button>
      </div>
    </div>
    <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close"><i data-lucide="x"></i></button>
  </div>

  
  <div class="toast" data-state="open" role="status" aria-live="polite" aria-atomic="true" style="--toast-icon-color: var(--st-foreground);">
    <div class="toast__icon"><i data-lucide="git-pull-request"></i></div>
    <div class="toast__content">
      <div class="toast__header">Review requested</div>
      <div class="toast__body">Maya tagged you on "fix(auth): rotate session keys".</div>
    </div>
    <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close"><i data-lucide="x"></i></button>
  </div>

  
  <div class="toast" data-state="open" role="status" aria-live="polite" aria-atomic="true" style="--toast-icon-size: 2.25rem;">
    <div class="toast__icon">
      <span class="icon-box icon-box--primary"><i data-lucide="rocket"></i></span>
    </div>
    <div class="toast__content">
      <div class="toast__header">Deployment succeeded</div>
      <div class="toast__body">v2.4.1 is live on staging. Production rollout queued for 4 PM.</div>
      <div class="toast__action">
        <button type="button" class="btn btn--sm btn--ghost btn--primary btn--flush-start">View deploy</button>
      </div>
    </div>
    <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close"><i data-lucide="x"></i></button>
  </div>

  
  <div class="toast" data-state="open" role="status" aria-live="polite" aria-atomic="true">
    <div class="toast__icon">
      <span class="spinner spinner--sm" role="status" aria-label="Loading"></span>
    </div>
    <div class="toast__content">
      <div class="toast__header">
        Uploading 3 files
        <span class="toast__timestamp">62%</span>
      </div>
      <div class="toast__body">Don't close this window until upload completes.</div>
    </div>
    <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close"><i data-lucide="x"></i></button>
  </div>

  
  <div class="toast" data-state="open" role="status" aria-live="polite" aria-atomic="true" style="--toast-icon-size: 1.5rem;">
    <div class="toast__icon fs-5 lh-1">🎉</div>
    <div class="toast__content">
      <div class="toast__header">First sale!</div>
      <div class="toast__body">Pro plan subscription from sarah@acme.co. Cha-ching.</div>
    </div>
    <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close"><i data-lucide="x"></i></button>
  </div>

</div>

Trigger

Markup-first toasts always live inside a .toast-region. The region is position: fixed and pins to a viewport corner. The trigger button then points at a toast by id, calls open(), and starts the autohide timer; re-clicking restarts the timer. Use data-stisla-toast-autohide and data-stisla-toast-delay on the toast root to retune per-instance.

<button type="button" class="btn btn--primary" data-stisla-toast-trigger="triggerToast">Show toast</button>

<div class="toast-region toast-region--top-end" role="region" aria-label="Notifications">
  <div class="toast" id="triggerToast"
       data-stisla-toast
       data-stisla-toast-delay="4000"
       data-state="closed"
       role="status" aria-live="polite" aria-atomic="true" aria-hidden="true">
    <div class="toast__icon"><i data-lucide="link"></i></div>
    <div class="toast__content">
      <div class="toast__header">Link copied</div>
      <div class="toast__body">Anyone with the link can view the page.</div>
    </div>
    <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close"><i data-lucide="x"></i></button>
  </div>
</div>

Auto-hide and persistent

Auto-hide is on by default with a 4-second delay. Hovering or focusing the toast pauses the timer (WCAG 2.2.1) and leaving resumes it. Set data-stisla-toast-autohide="false" for a sticky toast that only the close button dismisses, useful for errors and confirmations.

<div class="d-flex flex-wrap gap-2">
  <button type="button" class="btn btn--outline btn--neutral" data-stisla-toast-trigger="autoHideToast">Auto-hide (4s)</button>
  <button type="button" class="btn btn--outline btn--neutral" data-stisla-toast-trigger="persistentToast">Persistent</button>
</div>

<div class="toast-region toast-region--top-end" role="region" aria-label="Notifications">
  <div class="toast toast--success" id="autoHideToast"
       data-stisla-toast
       data-stisla-toast-delay="4000"
       data-state="closed"
       role="status" aria-live="polite" aria-atomic="true" aria-hidden="true">
    <div class="toast__icon"><i data-lucide="check-circle-2"></i></div>
    <div class="toast__content">
      <div class="toast__header">Saved</div>
      <div class="toast__body">Hover to pause the timer.</div>
    </div>
    <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close"><i data-lucide="x"></i></button>
  </div>

  <div class="toast toast--danger" id="persistentToast"
       data-stisla-toast
       data-stisla-toast-autohide="false"
       data-state="closed"
       role="alert" aria-live="assertive" aria-atomic="true" aria-hidden="true">
    <div class="toast__icon"><i data-lucide="x-circle"></i></div>
    <div class="toast__content">
      <div class="toast__header">Couldn't sync</div>
      <div class="toast__body">Dismiss manually. The toast stays until you close it.</div>
    </div>
    <button type="button" class="toast__close" data-stisla-toast-dismiss aria-label="Close"><i data-lucide="x"></i></button>
  </div>
</div>

Position

Six modifiers on .toast-region set the corner. --top-start, --top-center (default), --top-end, --bottom-start, --bottom-center, and --bottom-end. Bottom regions stack newest-first at the bottom edge. Name each region with data-stisla-toast-region so Stisla.toast({ region }) can target it.

<div class="d-grid gap-2" style="grid-template-columns: repeat(3, 1fr);">
  <button type="button" class="btn btn--outline btn--neutral" data-demo-toast-position="top-start">Top start</button>
  <button type="button" class="btn btn--outline btn--neutral" data-demo-toast-position="top-center">Top center</button>
  <button type="button" class="btn btn--outline btn--neutral" data-demo-toast-position="top-end">Top end</button>
  <button type="button" class="btn btn--outline btn--neutral" data-demo-toast-position="bottom-start">Bottom start</button>
  <button type="button" class="btn btn--outline btn--neutral" data-demo-toast-position="bottom-center">Bottom center</button>
  <button type="button" class="btn btn--outline btn--neutral" data-demo-toast-position="bottom-end">Bottom end</button>
</div>

JS helper

Stisla.toast(opts) builds the toast node, mounts it into a region, opens it, and removes it from the DOM after close. Returns the Toast instance so callers can .close() manually. If no region exists in the page, the helper appends a default top-center region to <body> on first call (see Setup).

// Object-form
Stisla.toast({
  title: "Saved",
  description: "Your changes are saved.",
  variant: "success",
  action: { label: "Undo", onClick: () => undoLastEdit() },
  delay: 4000,
});

// Named shortcuts — same opts, intent + default icon prefilled
Stisla.toast.success("Saved");
Stisla.toast.error("Couldn't upload", { description: "Network dropped." });
Stisla.toast.warning("Storage almost full");
Stisla.toast.info("New version available");
<div class="d-flex flex-wrap gap-2">
  <button type="button" class="btn btn--ghost btn--neutral" data-demo-toast-shortcut="success">Success</button>
  <button type="button" class="btn btn--ghost btn--neutral" data-demo-toast-shortcut="error">Error</button>
  <button type="button" class="btn btn--ghost btn--neutral" data-demo-toast-shortcut="warning">Warning</button>
  <button type="button" class="btn btn--ghost btn--neutral" data-demo-toast-shortcut="info">Info</button>
  <button type="button" class="btn btn--primary" data-demo-toast-action>With action</button>
</div>

Promise

Stisla.toast.promise() shows a loading toast (spinner icon, autohide off), then patches it in place when the promise settles. The success and error fields accept strings or functions of (data) / (err). The original promise is not intercepted. Callers still await and .catch() as usual.

Stisla.toast.promise(savePromise, {
  loading: "Saving changes…",
  success: (data) => `Saved ${data.name}`,
  error:   (err) => `Failed: ${err.message}`,
  delay: 4000, // post-settle autohide window
});
<button type="button" class="btn btn--primary" data-demo-toast-promise>Run promise demo</button>

Customization

Twenty-eight variables retune .toast and .toast-region without touching component CSS. Override on a single instance, on the region, on a wrapper, or on :root. Intent modifiers shift only --toast-icon-color, so the surface stays neutral and a stack of mixed intents reads as one family.

Region geometry

VariableDefaultUse
--toast-region-inset1remDistance from viewport edges
--toast-region-gap1remVertical space between stacked toasts
--toast-region-max-width24remRegion cap so wide viewports don't stretch the stack
--toast-region-z-index1090Above dialog (1055) so a toast can confirm a dialog action

Geometry

VariableDefaultUse
--toast-min-width18remFloor so short titles still feel substantial
--toast-max-width21.875rem350px ceiling so long bodies wrap before getting unwieldy
--toast-padding1remSingle frame pad on the root
--toast-radiusvar(--st-radius-lg)Large-frame tier (matches dialog and card)
--toast-z-index1090Individual toast stacking

Surface

VariableDefaultUse
--toast-bgvar(--st-surface)Card-tier surface
--toast-colorvar(--st-foreground)Title color
--toast-border-colorvar(--st-border)Opaque border (not the translucent variant)
--toast-shadowvar(--st-shadow)Default ambient shadow

Layout

VariableDefaultUse
--toast-column-gap0.75remGrid column gap between icon, content, and close
--toast-content-gap0.25remVertical gap inside the content stack (title, body, action)
VariableDefaultUse
--toast-header-font-weight600Title weight
--toast-header-font-size0.875remTitle size

Body

VariableDefaultUse
--toast-body-colorvar(--st-muted-foreground)Description text color
--toast-body-font-size0.875remDescription size

Icon

VariableDefaultUse
--toast-icon-size1.125remLeading icon footprint
--toast-icon-colorvar(--st-muted-foreground)Intent modifiers retune this var and nothing else

Close chip

VariableDefaultUse
--toast-close-size1.5remTap target footprint
--toast-close-colorvar(--st-muted-foreground)Rest icon color
--toast-close-color-hovervar(--st-foreground)Icon color on hover/focus
--toast-close-bg-hovervar(--st-accent)Chip fill on hover/focus

Action and motion

VariableDefaultUse
--toast-action-gap0.25remSpace between action-row buttons
--toast-transition-duration0.2sOpen and close fade plus slide
--toast-enter-transformtranslateY(-0.5rem)Direction the toast slides from on entry (bottom regions flip to translateY(0.5rem))