API
Toaster
Stacked toast notifications fired by a command.
Installation
pnpm dlx zazz-ui add toasternpx zazz-ui add toasteryarn dlx zazz-ui add toasterbunx zazz-ui add toasterAlso adds fields, kbd, button, popover, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.
Examples
<div
style="
--display: flex;
--flex-wrap: wrap;
--items: center;
--gap: 4;
"
>
<button
data-ui="button"
type="button"
commandfor="toaster-example"
command="--toast"
data-toaster-title="Event created"
data-toaster-description="Monday, July 13 at 9:00 AM"
>
Show toast
</button>
<button
data-ui="button"
type="button"
commandfor="toaster-example"
command="--toast-success"
data-toaster-title="Changes saved"
>
Success
</button>
<button
data-ui="button"
type="button"
commandfor="toaster-example"
command="--toast-info"
data-toaster-title="Update available"
>
Info
</button>
<button
data-ui="button"
type="button"
commandfor="toaster-example"
command="--toast-warning"
data-toaster-title="Storage almost full"
>
Warning
</button>
<button
data-ui="button"
type="button"
commandfor="toaster-example"
command="--toast-destructive"
data-toaster-title="Something went wrong"
data-toaster-description="The request could not be completed."
>
Error
</button>
<button data-ui="button" data-button-variant="primary" type="button" id="toaster-action-example">
With action
</button>
</div>
<ui-toaster id="toaster-example" popover="manual"></ui-toaster>
<script type="module">
// An action callback can't live in a data attribute, so it needs the imperative API.
await customElements.whenDefined("ui-toaster");
const toastTrigger = document.getElementById("toaster-action-example");
const handler = () => {
window.Toaster.toast({
title: "Email archived",
description: "The conversation was moved to the archive.",
action: { label: "Undo", onClick: () => window.Toaster.success("Email restored") },
});
};
toastTrigger.addEventListener("click", handler);
window.addEventListener("unload", () => {
toastTrigger.removeEventListener("click", handler);
});
</script>/**
* toaster.css: Toaster (ui-toaster | [data-ui~="toaster"], [data-toaster-slot~="toast"])
*
* Stacked toast notifications: newest toast in front, older toasts peeking
* behind, expand on hover.
*
* @layer variables, components
* @requires _layers.css, _variables.css, popover.css, button.css
* @uses popover="manual" + showPopover()/hidePopover(): top-layer region,
* driven by zazz/scripts/toaster.js (no light dismiss)
* @uses @starting-style: enter motion for toasts inserted into the open region
* @uses view-transition-name: toaster: own snapshot group, frozen in
* _view-transitions.css so page transitions never repaint the stack
* @uses JS-maintained custom properties (--toasts-before, --offset, --initial-height,
* --front-toast-height + inline z-index): stack math from toaster.js
* @tokens --ui-toaster-* (@layer variables)
*/
@layer variables {
:root {
/* surface */
--ui-toaster-bg: var(--color-popover);
--ui-toaster-text: var(--color-popover-foreground);
--ui-toaster-border-color: var(--color-border);
--ui-toaster-rounded: var(--radius-md);
--ui-toaster-shadow: var(--shadow-md);
/* status accent: icon color; variants reassign */
--ui-toaster-accent: var(--color-popover-foreground);
/* metrics: mobile full width falls out of the min(); no media query */
--ui-toaster-w: min(356px, calc(100vi - var(--gutters) * 2));
--ui-toaster-inset: var(--gutters);
--ui-toaster-gap: calc(var(--spacing) * 3.5);
--ui-toaster-p: calc(var(--spacing) * 4);
--ui-toaster-icon-size: calc(var(--spacing) * 5);
/* type */
--ui-toaster-title-font-size: var(--font-size-sm);
--ui-toaster-title-font-weight: var(--font-weight-strong);
--ui-toaster-description-font-size: var(--font-size-sm);
--ui-toaster-description-text: var(--color-muted-foreground);
/* stack: per-depth shrink of collapsed toasts */
--ui-toaster-scale-step: 0.05;
/* motion */
--ui-toaster-transition-duration: calc(var(--default-transition-duration) * 1.5);
--ui-toaster-transition-timing-function: var(--default-transition-timing-function);
--ui-toaster-opacity--start: 0;
--ui-toaster-opacity--end: 1;
}
}
@layer ui.components {
/* ===========================================================================
REGION: positioning shell in the top layer, not a surface, so the shared
[popover] chrome is neutralized. --_lift is set on the region and read by
every toast for the stack direction (-1 = up from the bottom edge, 1 = down
from the top).
=========================================================================== */
:where(ui-toaster, [data-ui~="toaster"]) {
/* neutralize shared [popover] chrome + UA popover styles */
background: none;
border: none;
box-shadow: none;
padding: 0;
margin: 0;
min-inline-size: unset;
max-inline-size: unset;
backdrop-filter: none;
opacity: 1;
transform: none;
transition: none;
overflow: visible;
position-area: none;
position: fixed;
inset: auto;
inset-block-end: var(--ui-toaster-inset);
inset-inline-end: var(--ui-toaster-inset);
inline-size: var(--ui-toaster-w);
--_lift: -1;
/* own frozen snapshot group so page transitions never repaint the stack;
one region per page, since duplicate names skip the whole transition */
view-transition-name: toaster;
}
:where(ui-toaster, [data-ui~="toaster"]):popover-open {
display: block;
}
/* logical positions flip for RTL; default is bottom-end */
:where(ui-toaster, [data-ui~="toaster"])[data-toaster-position^="top"] {
inset-block-start: var(--ui-toaster-inset);
inset-block-end: auto;
--_lift: 1;
}
:where(ui-toaster, [data-ui~="toaster"])[data-toaster-position$="start"] {
inset-inline-start: var(--ui-toaster-inset);
inset-inline-end: auto;
}
:where(ui-toaster, [data-ui~="toaster"])[data-toaster-position$="center"] {
inset-inline-start: calc(50vi - var(--ui-toaster-w) / 2);
inset-inline-end: auto;
}
:where(ui-toaster, [data-ui~="toaster"]) [data-toaster-slot~="list"] {
list-style: none;
margin: 0;
padding: 0;
}
/* ===========================================================================
TOAST: placement is transform-based so enter/exit/stack shifts share one
transition.
=========================================================================== */
:where(ui-toaster, [data-ui~="toaster"]) [data-toaster-slot~="toast"] {
position: absolute;
inset-inline: 0;
inset-block-end: 0;
display: flex;
align-items: center;
gap: calc(var(--spacing) * 2);
inline-size: 100%;
padding: var(--ui-toaster-p);
color: var(--ui-toaster-text);
background-color: var(--ui-toaster-bg);
border: 1px solid var(--ui-toaster-border-color);
border-radius: var(--ui-toaster-rounded);
box-shadow: var(--ui-toaster-shadow);
overflow-wrap: anywhere;
--_y: translateY(calc(var(--_lift) * var(--offset, 0px)));
transform: var(--_y);
opacity: var(--ui-toaster-opacity--end);
transition:
transform var(--ui-toaster-transition-duration) var(--ui-toaster-transition-timing-function),
opacity var(--ui-toaster-transition-duration) var(--ui-toaster-transition-timing-function),
block-size var(--ui-toaster-transition-duration) var(--ui-toaster-transition-timing-function);
/* slide in from the region's edge (JS opens the region before appending) */
@starting-style {
opacity: var(--ui-toaster-opacity--start);
transform: translateY(calc(var(--_lift) * -100%));
}
}
:where(ui-toaster, [data-ui~="toaster"])[data-toaster-position^="top"]
[data-toaster-slot~="toast"] {
inset-block-end: auto;
inset-block-start: 0;
}
/* content crossfades on collapse/expand */
:where(ui-toaster, [data-ui~="toaster"]) [data-toaster-slot~="toast"] > * {
transition: opacity var(--ui-toaster-transition-duration)
var(--ui-toaster-transition-timing-function);
}
/* ===========================================================================
COLLAPSED STACK: behind toasts tuck under the front one: shifted by one
gap per depth, scaled down, clamped to the front toast's height so only
their edges peek, content hidden.
=========================================================================== */
:where(ui-toaster, [data-ui~="toaster"]):not([data-toaster-state~="expanded"])
[data-toaster-slot~="toast"]:not(
[data-toaster-state~="front"],
[data-toaster-state~="removed"]
) {
--_y: translateY(calc(var(--_lift) * var(--ui-toaster-gap) * var(--toasts-before, 0)))
scale(calc(1 - var(--ui-toaster-scale-step) * var(--toasts-before, 0)));
block-size: var(--front-toast-height);
}
:where(ui-toaster, [data-ui~="toaster"]):not([data-toaster-state~="expanded"])
[data-toaster-slot~="toast"]:not([data-toaster-state~="front"], [data-toaster-state~="removed"])
> * {
opacity: 0;
}
/* ===========================================================================
EXPANDED: real cumulative offsets and natural heights. The ::after bridge
spans the gap toward the next toast so the pointer never leaves the stack
while moving between toasts (which would collapse it).
=========================================================================== */
:where(ui-toaster, [data-ui~="toaster"])[data-toaster-state~="expanded"]
[data-toaster-slot~="toast"] {
block-size: var(--initial-height, auto);
}
:where(ui-toaster, [data-ui~="toaster"])[data-toaster-state~="expanded"]
[data-toaster-slot~="toast"]::after {
content: "";
position: absolute;
inset-inline: 0;
inset-block-end: 100%;
block-size: calc(var(--ui-toaster-gap) + 1px);
}
:where(
ui-toaster,
[data-ui~="toaster"]
)[data-toaster-position^="top"][data-toaster-state~="expanded"]
[data-toaster-slot~="toast"]::after {
inset-block-end: auto;
inset-block-start: 100%;
}
/* beyond the visible limit: stays in the DOM but hidden */
:where(ui-toaster, [data-ui~="toaster"])
[data-toaster-slot~="toast"]:not([data-toaster-state~="visible"]) {
opacity: 0;
pointer-events: none;
}
/* ===========================================================================
EXIT: transition back toward the region's edge; toaster.js removes the
node on transitionend. Collapsed behind toasts keep their tucked transform
and simply fade, so removal doesn't jump the stack.
=========================================================================== */
/* keep the dying toast pointer-active: dropping it from hit-testing would
fire mouseleave and collapse an expanded stack mid-dismiss */
:where(ui-toaster, [data-ui~="toaster"])
[data-toaster-slot~="toast"][data-toaster-state~="removed"] {
opacity: var(--ui-toaster-opacity--start);
--_y: translateY(calc(var(--_lift) * var(--offset, 0px) + var(--_lift) * -100%));
}
:where(ui-toaster, [data-ui~="toaster"]):not([data-toaster-state~="expanded"])
[data-toaster-slot~="toast"][data-toaster-state~="removed"]:not([data-toaster-state~="front"]) {
--_y: translateY(calc(var(--_lift) * var(--ui-toaster-gap) * var(--toasts-before, 0)))
scale(calc(1 - var(--ui-toaster-scale-step) * var(--toasts-before, 0)));
}
:where(ui-toaster, [data-ui~="toaster"]) [data-toaster-slot~="icon"] {
flex-shrink: 0;
inline-size: var(--ui-toaster-icon-size);
block-size: var(--ui-toaster-icon-size);
color: var(--ui-toaster-accent);
}
:where(ui-toaster, [data-ui~="toaster"]) [data-toaster-slot~="icon"] > svg {
display: block;
inline-size: 100%;
block-size: 100%;
}
:where(ui-toaster, [data-ui~="toaster"]) [data-toaster-slot~="content"] {
flex: 1;
min-inline-size: 0;
display: flex;
flex-direction: column;
gap: var(--spacing);
}
:where(ui-toaster, [data-ui~="toaster"]) [data-toaster-slot~="title"] {
font-size: var(--ui-toaster-title-font-size);
font-weight: var(--ui-toaster-title-font-weight);
line-height: var(--leading-sm);
}
:where(ui-toaster, [data-ui~="toaster"]) [data-toaster-slot~="description"] {
font-size: var(--ui-toaster-description-font-size);
line-height: var(--leading-sm);
color: var(--ui-toaster-description-text);
}
:where(ui-toaster, [data-ui~="toaster"]) [data-toaster-slot~="action"],
:where(ui-toaster, [data-ui~="toaster"]) [data-toaster-slot~="close"] {
flex-shrink: 0;
}
/* ===========================================================================
VARIANTS: reassign the accent token only
=========================================================================== */
:where(ui-toaster, [data-ui~="toaster"])
[data-toaster-slot~="toast"][data-toaster-variant="success"] {
--ui-toaster-accent: var(--color-success);
}
:where(ui-toaster, [data-ui~="toaster"])
[data-toaster-slot~="toast"][data-toaster-variant="info"] {
--ui-toaster-accent: var(--color-info);
}
:where(ui-toaster, [data-ui~="toaster"])
[data-toaster-slot~="toast"][data-toaster-variant="warning"] {
--ui-toaster-accent: var(--color-warning);
}
:where(ui-toaster, [data-ui~="toaster"])
[data-toaster-slot~="toast"][data-toaster-variant="destructive"] {
--ui-toaster-accent: var(--color-destructive);
}
}// primitives/toaster/toaster.js
"use strict";
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
if (value !== null && value !== void 0) {
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
var dispose, inner;
if (async) {
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
dispose = value[Symbol.asyncDispose];
}
if (dispose === void 0) {
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
dispose = value[Symbol.dispose];
if (async) inner = dispose;
}
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
env.stack.push({ value: value, dispose: dispose, async: async });
}
else if (async) {
env.stack.push({ async: true });
}
return value;
};
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
return function (env) {
function fail(e) {
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
env.hasError = true;
}
var r, s = 0;
function next() {
while (r = env.stack.pop()) {
try {
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
if (r.dispose) {
var result = r.dispose.call(r.value);
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
}
else s |= 1;
}
catch (e) {
fail(e);
}
}
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
if (env.hasError) throw env.error;
}
return next();
};
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
});
/**
* @fileoverview `<ui-toaster>`: HTML web component for stacked toast notifications.
* @description Light-DOM custom element that hosts a top-layer toast stack, plus
* the `window.Toaster` imperative API. Timers pause on hover and in hidden tabs.
*
* The region is a `popover="manual"` element: it enters the top layer via
* `showPopover()` when the first toast arrives and leaves it after the last
* toast's exit transition. Toasts are plain `<li>` children, so the collapsed
* stack offsets in `toaster.css` work with normal CSS transforms.
*
* Fire toasts two ways:
* - Declaratively, from any button, via a custom Invoker Command:
* `command="--toast"` (or `--toast-success|info|warning|destructive`) with
* `commandfor="<region id>"`. Toast content comes from the button's
* `data-toaster-title`, `data-toaster-description`, `data-toaster-variant`,
* `data-toaster-duration`, and `data-toaster-close-button` attributes.
* - Imperatively: `window.Toaster.toast({ title, description, variant, … })`
* and the `.success()/.info()/.warning()/.error()` shorthands.
*
* Region attributes:
* - `data-toaster-position`: `top-start | top-center | top-end | bottom-start |
* bottom-center | bottom-end` (logical; default `bottom-end`).
*
* @example
* <ui-toaster id="toaster" popover="manual"></ui-toaster>
* <button commandfor="toaster" command="--toast" data-toaster-title="Saved">Save</button>
*/
import { computed, effect, state } from "../../base/signals.js";
import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";
// compiled `using` reads Symbol.dispose, which engines without native Explicit
// Resource Management (Safari) lack
Symbol.dispose ??= Symbol("Symbol.dispose");
/** Default toast lifetime in milliseconds. */
const TOAST_LIFETIME = 4000;
/** Maximum number of toasts shown in the collapsed stack. */
const VISIBLE_TOASTS = 3;
/** Safety net for node removal when no exit `transitionend` fires. */
const EXIT_FALLBACK_MS = 600;
const VARIANTS = ["success", "info", "warning", "destructive"];
const ICONS = {
success: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd"/></svg>',
info: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd"/></svg>',
warning: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd"/></svg>',
destructive: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></svg>',
close: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
};
/**
* @description Unclamps every toast's block-size for a batch measurement (the
* inline style beats the collapsed block-size rule) and returns a disposer
* that restores the clamp. Bind it with `using` so the restore is guaranteed
* at scope exit, even if a measure in between throws.
*
* @param toasts - The live toasts to unclamp.
* @returns A `Disposable` that removes the inline block-size again.
* @private
*/
function unclampForMeasure(toasts) {
for (const toast of toasts)
toast.style.blockSize = "auto";
return {
[Symbol.dispose]() {
for (const toast of toasts)
toast.style.removeProperty("block-size");
},
};
}
/**
* @description Computes the collapsed-stack placement for every toast from the
* measured heights alone (oldest first, matching DOM order). Pure: the
* measure step feeds it and an effect writes the results to the DOM, so this
* is the unit-testable core of the stacking model.
*
* @param heights - Natural toast heights in px, oldest first.
* @returns Per-toast layout (same order) and the front toast's height.
*/
function computeStackLayout(heights) {
const count = heights.length;
const toasts = Array.from({ length: count });
let heightsBefore = 0;
for (let i = count - 1; i >= 0; i--) {
const stackIndex = count - 1 - i; // 0 = front (newest, last in DOM)
toasts[i] = {
stackIndex,
offsetPx: heightsBefore,
zIndex: count - stackIndex,
front: stackIndex === 0,
visible: stackIndex < VISIBLE_TOASTS,
};
heightsBefore += heights[i];
}
return { toasts, frontToastHeightPx: count > 0 ? heights[count - 1] : null };
}
/**
* @class
* @description Hosts the toast stack: builds toast markup, maintains the
* collapsed-stack CSS custom properties, runs auto-dismiss timers, and shows or
* hides the `popover="manual"` region as toasts come and go.
*/
/**
* @description Adds or removes one token of an element's `data-toaster-state`
* list: `expanded` on the region; `front`, `visible`, `removed` on a toast.
*/
function setToasterState(node, token, on) {
const tokens = (node.getAttribute("data-toaster-state") ?? "").split(/\s+/).filter(Boolean);
const next = on ? [...new Set([...tokens, token])] : tokens.filter((t) => t !== token);
if (next.length)
node.setAttribute("data-toaster-state", next.join(" "));
else
node.removeAttribute("data-toaster-state");
}
/** @description Whether a toast has been dismissed and is animating out. */
function isRemoved(toast) {
return toast.matches('[data-toaster-state~="removed"]');
}
class UiToaster extends ZazzElement {
#timers = new Map();
#counter = 0;
#resizeFrame = 0;
#handledCommand = null;
#collapseTimer = 0;
// Signal state: DOM events and observers write in; computed holds the pure
// derivations; effects (bound in connectedCallback) write back to the DOM.
/** Whether the stack is expanded (hover/focus). */
#expanded = state(false);
/** Whether the tab is hidden (mirrors `document.hidden`). */
#hidden = state(typeof document !== "undefined" ? document.hidden : false);
/** Timers run only while neither expanded nor hidden. */
#paused = computed(() => this.#expanded.get() || this.#hidden.get());
/** Measured stack (oldest first): written by `#reindex`'s measure pass. */
#stack = state([]);
/** Pure placement derived from the measured heights. */
#layout = computed(() => computeStackLayout(this.#stack.get().map((entry) => entry.height)));
setup(signal) {
// Region baseline: manual popover (no light dismiss), reachable landmark.
if (!this.hasAttribute("popover"))
this.setAttribute("popover", "manual");
if (!this.hasAttribute("role"))
this.setAttribute("role", "region");
if (!this.hasAttribute("aria-label"))
this.setAttribute("aria-label", "Notifications");
if (!this.hasAttribute("tabindex"))
this.setAttribute("tabindex", "-1");
// The live region must exist before toasts are inserted so additions announce.
if (!this.querySelector('[data-toaster-slot~="list"]')) {
const list = document.createElement("ol");
list.setAttribute("data-toaster-slot", "list");
list.setAttribute("aria-live", "polite");
list.setAttribute("aria-relevant", "additions text");
list.setAttribute("aria-atomic", "false");
this.append(list);
}
this.addEventListener("command", (event) => this.#onCommand(event), { signal });
// Hover/focus expands the stack; expanded (or hidden tab) pauses timers.
// Leave events only *request* a collapse: dismissal animations and node
// removal fire spurious mouseleave/focusout while the pointer never moved,
// so the collapse is verified against real hover/focus state first.
this.addEventListener("mouseenter", () => this.#setExpanded(true), { signal });
this.addEventListener("mouseleave", () => this.#requestCollapse(), { signal });
this.addEventListener("focusin", () => this.#setExpanded(true), { signal });
this.addEventListener("focusout", () => this.#requestCollapse(), { signal });
document.addEventListener("visibilitychange", () => this.#hidden.set(document.hidden), {
signal,
});
// Viewport resizes rewrap toast text: remeasure the stack.
window.addEventListener("resize", () => {
cancelAnimationFrame(this.#resizeFrame);
this.#resizeFrame = requestAnimationFrame(() => this.#reindex());
}, { signal });
// Output effects (disposed by the same controller as the listeners):
// one owns the expanded attribute, one owns pausing/resuming the timers,
// one writes the computed stack placement to the DOM.
effect(() => {
setToasterState(this, "expanded", this.#expanded.get());
}, { signal });
effect(() => {
if (this.#paused.get()) {
this.#pauseTimers();
}
else {
this.#resumeTimers();
}
}, { signal });
effect(() => {
const entries = this.#stack.get();
const { toasts, frontToastHeightPx } = this.#layout.get();
for (let i = 0; i < entries.length; i++) {
const { node, height } = entries[i];
const layout = toasts[i];
setToasterState(node, "front", layout.front);
setToasterState(node, "visible", layout.visible);
node.style.zIndex = String(layout.zIndex);
node.style.setProperty("--toasts-before", String(layout.stackIndex));
node.style.setProperty("--initial-height", `${height}px`);
node.style.setProperty("--offset", `calc(${layout.offsetPx}px + var(--ui-toaster-gap) * ${layout.stackIndex})`);
}
if (frontToastHeightPx !== null) {
this.style.setProperty("--front-toast-height", `${frontToastHeightPx}px`);
}
}, { signal });
}
teardown() {
cancelAnimationFrame(this.#resizeFrame);
window.clearTimeout(this.#collapseTimer);
for (const timer of this.#timers.values())
clearTimeout(timer.timeoutId);
this.#timers.clear();
}
/**
* @description Adds a toast to this region and shows the region if needed.
*
* @param options - Toast content and behavior.
* @returns The toast id (usable with `dismiss()`).
*/
addToast(options = {}) {
const id = `toast-${++this.#counter}`;
const list = this.#list();
// Show the popover before inserting so the insertion is announced and the
// toast's @starting-style enter transition runs inside an open region.
this.#showRegion();
list.append(this.#buildToast(options, id));
this.#reindex();
this.#startTimer(id, options.duration ?? TOAST_LIFETIME);
return id;
}
/**
* @description Dismisses one toast by id, or every toast when omitted.
*
* @param id - Toast id returned by `addToast()`.
*/
dismiss(id) {
if (id === undefined) {
this.dismissAll();
return;
}
const toast = this.#list().querySelector(`[data-toaster-id="${CSS.escape(id)}"]`);
if (!(toast instanceof HTMLElement) || isRemoved(toast))
return;
const timer = this.#timers.get(id);
if (timer)
clearTimeout(timer.timeoutId);
this.#timers.delete(id);
setToasterState(toast, "removed", true);
// Keyboard users keep their place: hand focus to the next toast before this
// one goes. A mouse click's incidental focus is left to drop to <body>;
// holding it would pin the stack expanded after the pointer leaves, and
// #requestCollapse's :hover check already keeps the stack open meanwhile.
const active = document.activeElement;
if (active instanceof HTMLElement &&
toast.contains(active) &&
active.matches(":focus-visible")) {
this.#toasts()
.filter((t) => !isRemoved(t))
.at(-1)
?.focus({ preventScroll: true });
}
this.#reindex();
this.#finalizeRemoval(toast);
}
/**
* @description Dismisses every toast in this region.
*/
dismissAll() {
for (const toast of this.#toasts()) {
const toastId = toast.dataset.toasterId;
if (toastId)
this.dismiss(toastId);
}
}
/**
* @description Handles the custom `--toast` Invoker Command fired by buttons
* with `commandfor` pointing at this region. `--toast-success` (etc.) sets the
* variant; everything else comes from the invoker's `data-toaster-*` attributes.
*
* @param event - The command event.
*/
#onCommand(event) {
const command = event.command;
if (typeof command !== "string" || !command.startsWith("--toast"))
return;
// The invokers polyfill re-dispatches commands even in browsers with a
// native CommandEvent, so one click can deliver the same command twice in
// the same task. Handle the first and drop the same-task duplicate.
const handled = this.#handledCommand;
if (handled && handled.source === event.source && handled.command === command)
return;
this.#handledCommand = { source: event.source, command };
window.setTimeout(() => {
this.#handledCommand = null;
}, 0);
const source = event.source;
const options = {};
const suffix = command.slice("--toast-".length);
if (VARIANTS.includes(suffix)) {
options.variant = suffix;
}
if (source instanceof HTMLElement) {
const { toasterTitle: title, toasterDescription: description, toasterVariant: variant, toasterDuration: duration, toasterCloseButton: closeButton, } = source.dataset;
if (title)
options.title = title;
if (description)
options.description = description;
if (!options.variant && variant && VARIANTS.includes(variant)) {
options.variant = variant;
}
if (duration !== undefined) {
// A duration is only ever a number: parse it as one rather than running
// it through the polymorphic attribute parser and narrowing after.
// NaN-guarded rather than finite-guarded: `Infinity` is a documented
// value (persist until dismissed). Blank strings coerce to 0, so they
// are rejected before Number() sees them.
const parsed = duration.trim() === "" ? Number.NaN : Number(duration);
if (!Number.isNaN(parsed))
options.duration = parsed;
}
if (closeButton !== undefined)
options.closeButton = closeButton !== "false";
}
this.addToast(options);
}
/**
* @description Builds a toast `<li>`: status icon, title/description, and the
* optional action and close buttons. Text is set via `textContent`.
*
* @param options - Toast content and behavior.
* @param id - The generated toast id.
* @returns The toast element (not yet inserted).
*/
#buildToast(options, id) {
const toast = document.createElement("li");
toast.setAttribute("data-toaster-slot", "toast");
toast.dataset.toasterId = id;
toast.tabIndex = 0;
if (options.variant)
toast.dataset.toasterVariant = options.variant;
if (options.variant) {
const icon = document.createElement("span");
icon.setAttribute("data-toaster-slot", "icon");
icon.setAttribute("aria-hidden", "true");
icon.innerHTML = ICONS[options.variant];
toast.append(icon);
}
const content = document.createElement("div");
content.setAttribute("data-toaster-slot", "content");
if (options.title) {
const title = document.createElement("div");
title.setAttribute("data-toaster-slot", "title");
title.textContent = options.title;
content.append(title);
}
if (options.description) {
const description = document.createElement("div");
description.setAttribute("data-toaster-slot", "description");
description.textContent = options.description;
content.append(description);
}
toast.append(content);
const action = options.action;
if (action) {
const button = document.createElement("button");
button.type = "button";
button.dataset.ui = "button";
button.setAttribute("data-toaster-slot", "action");
button.dataset.buttonSize = "sm";
button.textContent = action.label;
button.addEventListener("click", (event) => {
action.onClick?.(event);
if (!event.defaultPrevented)
this.dismiss(id);
});
toast.append(button);
}
if (options.closeButton !== false) {
const close = document.createElement("button");
close.type = "button";
close.dataset.ui = "button";
close.setAttribute("data-toaster-slot", "close");
close.dataset.buttonVariant = "ghost";
close.dataset.buttonSize = "icon-sm";
close.setAttribute("aria-label", "Close notification");
close.innerHTML = ICONS.close;
close.addEventListener("click", () => this.dismiss(id));
toast.append(close);
}
return toast;
}
/**
* @description Remeasures the stack: the measure half of the stacking
* model. Batch-reads every live toast's natural height and writes the
* result into `#stack`; `#layout` derives the placement purely
* (`computeStackLayout`) and the stack effect writes the CSS custom
* properties `toaster.css` reads (`--toasts-before`, `--offset`,
* `--initial-height`, `--front-toast-height`) plus `data-toaster-state~="front"`,
* `data-toaster-state~="visible"`, and z-index. DOM order is chronological; the last child
* is the front (newest) toast.
*/
#reindex() {
const env_1 = { stack: [], error: void 0, hasError: false };
try {
const toasts = this.#toasts().filter((toast) => !isRemoved(toast));
// Batch-measure with heights unclamped, restored at scope exit: one
// layout pass, no visible change (the stack effect is microtask-batched,
// so its DOM writes land after the restore).
const _measure = __addDisposableResource(env_1, unclampForMeasure(toasts), false);
const entries = toasts.map((toast) => ({ node: toast, height: toast.offsetHeight }));
this.#stack.set(entries);
}
catch (e_1) {
env_1.error = e_1;
env_1.hasError = true;
}
finally {
__disposeResources(env_1);
}
}
/**
* @returns The toast list (created in `connectedCallback`).
*/
#list() {
let list = this.querySelector('[data-toaster-slot~="list"]');
if (!(list instanceof HTMLOListElement)) {
list = document.createElement("ol");
list.setAttribute("data-toaster-slot", "list");
this.append(list);
}
return list;
}
/**
* @returns All toast elements, oldest first (DOM order).
*/
#toasts() {
return Array.from(this.#list().children)
.filter((child) => child instanceof HTMLElement)
.filter((child) => child.matches('[data-toaster-slot~="toast"]'));
}
/**
* @description Expands or collapses the stack by writing the signal state.
* The attribute write and the timer pause/resume are owned
* by the effects in `connectedCallback`: no caller has to remember them.
*
* @param expanded - Whether the stack is expanded.
*/
#setExpanded(expanded) {
if (expanded)
window.clearTimeout(this.#collapseTimer);
this.#expanded.set(expanded);
}
/**
* @description Collapses the stack only if the user has really left it.
* Waits a beat, then checks actual hover and focus state: dismissals fire
* spurious mouseleave/focusout (exit transforms, node removal, focus drops)
* that a raw event handler would mistake for the pointer leaving.
*/
#requestCollapse() {
window.clearTimeout(this.#collapseTimer);
this.#collapseTimer = window.setTimeout(() => {
const activeElement = document.activeElement;
const focusWithin = activeElement instanceof Node && this.contains(activeElement);
if (!this.matches(":hover") && !focusWithin)
this.#setExpanded(false);
}, 100);
}
/**
* @description Registers a toast's auto-dismiss timer and schedules it unless
* the stack is currently paused (expanded or hidden tab).
*
* @param id - Toast id.
* @param duration - Lifetime in ms; `Infinity` skips the timer.
*/
#startTimer(id, duration) {
if (duration === Infinity || Number.isNaN(duration))
return;
const timer = { remaining: duration, startedAt: 0, timeoutId: 0 };
this.#timers.set(id, timer);
if (!this.#paused.get())
this.#schedule(id, timer);
}
/**
* @description Arms a timer's timeout for its remaining lifetime.
*
* @param id - Toast id.
* @param timer - The timer record.
*/
#schedule(id, timer) {
timer.startedAt = Date.now();
timer.timeoutId = window.setTimeout(() => this.dismiss(id), timer.remaining);
}
/**
* @description Pauses all running timers, banking each one's remaining time.
*/
#pauseTimers() {
for (const timer of this.#timers.values()) {
if (!timer.timeoutId)
continue;
clearTimeout(timer.timeoutId);
timer.timeoutId = 0;
timer.remaining = Math.max(0, timer.remaining - (Date.now() - timer.startedAt));
}
}
/**
* @description Resumes paused timers (no-op while still expanded or hidden).
*/
#resumeTimers() {
if (this.#paused.get())
return;
for (const [id, timer] of this.#timers) {
if (!timer.timeoutId)
this.#schedule(id, timer);
}
}
/**
* @description Puts the region on the top layer if it isn't already.
*/
#showRegion() {
try {
if (!this.matches(":popover-open"))
this.showPopover();
}
catch {
// without the Popover API the region stays a fixed-position element
}
}
/**
* @description Removes the region from the top layer.
*/
#hideRegion() {
try {
if (this.matches(":popover-open"))
this.hidePopover();
}
catch {
// See #showRegion.
}
}
/**
* @description Removes a dismissed toast after its exit transition (with a
* timeout safety net), then hides the region once the stack is empty.
*
* @param toast - The toast marked `data-toaster-state~="removed"`.
*/
#finalizeRemoval(toast) {
let done = false;
const remove = () => {
if (done)
return;
done = true;
toast.remove();
if (this.#toasts().length === 0) {
this.#setExpanded(false);
this.#hideRegion();
}
};
toast.addEventListener("transitionend", (event) => {
if (event.target === toast)
remove();
});
// Safety net for when no transitionend fires (reduced motion → duration 0).
// Scaled to the computed duration so retuned --ui-toaster-transition-duration
// themes aren't yanked out mid-exit.
const duration = getComputedStyle(toast)
.transitionDuration.split(",")
.reduce((max, value) => Math.max(max, Number.parseFloat(value) || 0), 0);
window.setTimeout(remove, Math.max(EXIT_FALLBACK_MS, duration * 1000 + 100));
}
}
/**
* @description Resolves a target region from an id, element, or the document.
*
* @param region - Region id or element.
* @returns The region, or `null` when none exists.
* @private
*/
function resolveRegion(region) {
const node = region instanceof Element
? region
: typeof region === "string"
? document.getElementById(region)
: document.querySelector('ui-toaster, [data-ui~="toaster"]');
if (node instanceof UiToaster)
return node;
console.warn('Toaster: no <ui-toaster> found. Add `<ui-toaster popover="manual"></ui-toaster>` to the page.');
return null;
}
/**
* @description Normalizes the `toast("message")` string shorthand.
*
* @param options - Options object or title string.
* @returns The options object.
* @private
*/
function toOptions(options) {
if (typeof options === "string")
return { title: options };
return options ?? {};
}
/**
* @namespace Toaster
* @description Imperative toast API. Requires a `<ui-toaster>` in the page:
* the region is never auto-created (HTML-first, like every Zazz component).
*
* @property toast - Shows a toast; returns its id.
* @property success - Success shorthand.
* @property info - Info shorthand.
* @property warning - Warning shorthand.
* @property error - Destructive shorthand.
* @property dismiss - Dismisses one toast, or all when omitted.
*/
const Toaster = {
/**
* @description Shows a toast in the target (or first) region.
*
* @param options - Options object or title string.
* @returns The toast id, or `null` when no region exists.
*/
toast(options) {
const resolved = toOptions(options);
const region = resolveRegion(resolved.region);
return region ? region.addToast(resolved) : null;
},
/**
* @description Shows a success toast.
*
* @param message - Toast title.
* @param options - Additional options.
* @returns The toast id, or `null` when no region exists.
*/
success(message, options) {
return Toaster.toast({ ...options, title: message, variant: "success" });
},
/**
* @description Shows an info toast.
*
* @param message - Toast title.
* @param options - Additional options.
* @returns The toast id, or `null` when no region exists.
*/
info(message, options) {
return Toaster.toast({ ...options, title: message, variant: "info" });
},
/**
* @description Shows a warning toast.
*
* @param message - Toast title.
* @param options - Additional options.
* @returns The toast id, or `null` when no region exists.
*/
warning(message, options) {
return Toaster.toast({ ...options, title: message, variant: "warning" });
},
/**
* @description Shows a destructive/error toast.
*
* @param message - Toast title.
* @param options - Additional options.
* @returns The toast id, or `null` when no region exists.
*/
error(message, options) {
return Toaster.toast({ ...options, title: message, variant: "destructive" });
},
/**
* @description Dismisses a toast by id in any region, or every toast everywhere.
*
* @param id - Toast id returned by `toast()`.
*/
dismiss(id) {
for (const region of document.querySelectorAll('ui-toaster, [data-ui~="toaster"]')) {
if (region instanceof UiToaster)
region.dismiss(id);
}
},
};
defineZazzElement("ui-toaster", UiToaster);
if (typeof window !== "undefined") {
window.Toaster = Toaster;
}
export { Toaster, UiToaster, computeStackLayout };Behavior
primitives/toaster/toaster.js
<ui-toaster>: HTML web component for stacked toast notifications. Light-DOM custom element that hosts a top-layer toast stack, plus the window.Toaster imperative API. Timers pause on hover and in hidden tabs.
The region is a popover="manual" element: it enters the top layer via showPopover() when the first toast arrives and leaves it after the last toast's exit transition. Toasts are plain <li> children, so the collapsed stack offsets in toaster.css work with normal CSS transforms.
Fire toasts two ways:
- Declaratively, from any button, via a custom Invoker Command:
command="--toast"(or--toast-success|info|warning|destructive) withcommandfor="<region id>". Toast content comes from the button'sdata-toaster-title,data-toaster-description,data-toaster-variant,data-toaster-duration, anddata-toaster-close-buttonattributes. - Imperatively:
window.Toaster.toast({ title, description, variant, … })and the.success()/.info()/.warning()/.error()shorthands.
Region attributes:
data-toaster-position:top-start | top-center | top-end | bottom-start | bottom-center | bottom-end(logical; defaultbottom-end).
Attributes
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-ui="toaster" | Identity | free value | Identity token in data-ui. |
<ui-toaster> | Tag form | free value | Tag form; styled and, when scripted, registered under this name. |
data-toaster-variant | Preset | destructive info success warning | Preset: a named bundle of values. |
data-toaster-slot | Slot | action close content description icon list title toast | Slot: a part inside the primitive. |
data-toaster-state | State | expanded front removed visible | State the kit's script writes; read it in selectors, never author it. |
data-toaster-close-button | Config | free value | Configuration the kit's script reads. |
data-toaster-description | Config | free value | Configuration the kit's script reads. |
data-toaster-duration | Config | free value | Configuration the kit's script reads. |
data-toaster-id | Config | free value | Configuration the kit's script reads. |
data-toaster-position | Config | free value | Configuration the kit's script reads. |
data-toaster-title | Config | free value | Configuration the kit's script reads. |
Hooks
Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.
| Hook | Default | State |
|---|---|---|
--ui-toaster-accent | var(--color-popover-foreground) | — |
--ui-toaster-bg | var(--color-popover) | — |
--ui-toaster-border-color | var(--color-border) | — |
--ui-toaster-description-font-size | var(--font-size-sm) | — |
--ui-toaster-description-text | var(--color-muted-foreground) | — |
--ui-toaster-gap | calc(var(--spacing) * 3.5) | — |
--ui-toaster-icon-size | calc(var(--spacing) * 5) | — |
--ui-toaster-inset | var(--gutters) | — |
--ui-toaster-opacity--end | 1 | end |
--ui-toaster-opacity--start | 0 | start |
--ui-toaster-p | calc(var(--spacing) * 4) | — |
--ui-toaster-rounded | var(--radius-md) | — |
--ui-toaster-scale-step | 0.05 | — |
--ui-toaster-shadow | var(--shadow-md) | — |
--ui-toaster-text | var(--color-popover-foreground) | — |
--ui-toaster-title-font-size | var(--font-size-sm) | — |
--ui-toaster-title-font-weight | var(--font-weight-strong) | — |
--ui-toaster-transition-duration | calc(var(--default-transition-duration) * 1.5) | — |
--ui-toaster-transition-timing-function | var(--default-transition-timing-function) | — |
--ui-toaster-w | min(356px, calc(100vi - var(--gutters) * 2)) | — |