Toaster
Stacked toast notifications on the top layer — fired declaratively with invoker commands or imperatively with window.Toaster.
Toasts live in a <toast-region class="toaster" popover="manual"> — a light-DOM web
component that enters the top layer via the Popover API while any toast is visible.
Fire one from any button with commandfor="<region id>" and the custom
command="--toast", or from JavaScript with window.Toaster.
Toasts auto-dismiss after ~4 seconds; timers pause while you hover the stack or the tab is hidden. The newest toast sits in front with up to three visible — hover to expand the stack into a full list. The stacking model is adapted from Sonner by Emil Kowalski.
Place the region once per page, outside <main> (e.g. at the end of <body>):
the SPA navigation swap replaces <main> wholesale, and a region outside it rides
through untouched. The stack is also excluded from page view transitions — it names
its own frozen snapshot group (view-transition-name: toaster), so neither
same-document swaps nor cross-document navigations cross-fade or repaint it.
Default
<div class="flex flex-wrap items-center gap-sm"> <button class="button" type="button" commandfor="toaster-example" command="--toast" data-title="Event created" data-description="Monday, July 13 at 9:00 AM" > Show toast </button> <button class="button" type="button" commandfor="toaster-example" command="--toast-success" data-title="Changes saved" > Success </button> <button class="button" type="button" commandfor="toaster-example" command="--toast-info" data-title="Update available" > Info </button> <button class="button" type="button" commandfor="toaster-example" command="--toast-warning" data-title="Storage almost full" > Warning </button> <button class="button" type="button" commandfor="toaster-example" command="--toast-destructive" data-title="Something went wrong" data-description="The request could not be completed." > Error </button> <button class="button" data-variant="primary" type="button" id="toaster-action-example"> With action </button></div><toast-region class="toaster" id="toaster-example" popover="manual"></toast-region><script type="module"> // Toasts with an action button need the imperative API (a callback can't live // in a data attribute). Everything else above is wired declaratively. await customElements.whenDefined("toast-region"); 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); // Optional: If you need to clean up immediately after the event or on some app shutdown: // Example cleanup on page unload for demo purposes: window.addEventListener("unload", () => { toastTrigger.removeEventListener("click", handler); });</script>// toaster.js"use strict";/** * @fileoverview `<toast-region>` — 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. The stacking model (newest toast in front, * older toasts peeking behind, expand on hover, timer pause on hover/hidden tab) * is adapted from Sonner by Emil Kowalski — https://sonner.emilkowal.ski (MIT). * * 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-title`, `data-description`, `data-variant`, `data-duration`, and * `data-close-button` attributes. * - Imperatively: `window.Toaster.toast({ title, description, variant, … })` * and the `.success()/.info()/.warning()/.error()` shorthands. * * Region attributes: * - `data-position` — `top-start | top-center | top-end | bottom-start | * bottom-center | bottom-end` (logical; default `bottom-end`). * * @see https://developer.mozilla.org/en-US/docs/Web/API/Popover_API * @see https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API * * @example * <toast-region class="toaster" id="toaster" popover="manual"></toast-region> * <button commandfor="toaster" command="--toast" data-title="Saved">Save</button> */import { Utils } from "./utils.js";// --- Constants ---/** 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;/** @type {ReadonlyArray<string>} */const VARIANTS = ["success", "info", "warning", "destructive"];// --- Icons (adapted from Sonner's assets.tsx — MIT, Emil Kowalski) ---/** @type {Record<string, string>} */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>',};// --- Typedefs ---/** * @typedef {Object} ToastAction * @property {string} label - Button label. * @property {(event: MouseEvent) => void} [onClick] - Click handler. Call * `event.preventDefault()` to keep the toast open. *//** * @typedef {Object} ToastOptions * @property {string} [title] - Toast heading. * @property {string} [description] - Supporting copy under the title. * @property {"success"|"info"|"warning"|"destructive"} [variant] - Status accent + icon. * @property {number} [duration=4000] - Lifetime in ms; `Infinity` persists until dismissed. * @property {ToastAction} [action] - Optional action button. * @property {boolean} [closeButton=true] - Render the explicit close button. * @property {string|Element} [region] - Target region id or element (default: first `<toast-region>`). *//** * @typedef {Object} ToastTimer * @property {number} remaining - Milliseconds left when next resumed. * @property {number} startedAt - `Date.now()` at the last (re)schedule. * @property {number} timeoutId - Active timeout id, or `0` while paused. */// --- <toast-region> element ---/** * @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. */class ToastRegion extends HTMLElement { /** @type {AbortController|null} */ #controller = null; /** @type {Map<string, ToastTimer>} */ #timers = new Map(); /** @type {number} */ #counter = 0; /** @type {number} */ #resizeFrame = 0; /** @type {{ source: Element|null, command: string }|null} */ #handledCommand = null; /** @type {number} */ #collapseTimer = 0; connectedCallback() { if (this.#controller) return; this.#controller = new AbortController(); const signal = this.#controller.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(".toaster__list")) { const list = document.createElement("ol"); list.className = "toaster__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", () => (document.hidden ? this.#pauseTimers() : this.#resumeTimers()), { signal }, ); // Viewport resizes rewrap toast text — remeasure the stack. window.addEventListener( "resize", () => { cancelAnimationFrame(this.#resizeFrame); this.#resizeFrame = requestAnimationFrame(() => this.#reindex()); }, { signal }, ); } disconnectedCallback() { this.#controller?.abort(); this.#controller = null; cancelAnimationFrame(this.#resizeFrame); window.clearTimeout(this.#collapseTimer); for (const timer of this.#timers.values()) clearTimeout(timer.timeoutId); this.#timers.clear(); } // --- Public API --- /** * @description Adds a toast to this region and shows the region if needed. * * @param {ToastOptions} [options={}] - Toast content and behavior. * @returns {string} 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 {string} [id] - Toast id returned by `addToast()`. * @returns {void} */ dismiss(id) { if (id === undefined) { this.dismissAll(); return; } const toast = this.#list().querySelector(`[data-toast-id="${CSS.escape(id)}"]`); if (!(toast instanceof HTMLElement) || toast.dataset.removed === "true") return; const timer = this.#timers.get(id); if (timer) clearTimeout(timer.timeoutId); this.#timers.delete(id); toast.dataset.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) => t.dataset.removed !== "true") .at(-1) ?.focus({ preventScroll: true }); } this.#reindex(); this.#finalizeRemoval(toast); } /** * @description Dismisses every toast in this region. * * @returns {void} */ dismissAll() { for (const toast of this.#toasts()) { const toastId = toast.dataset.toastId; if (toastId) this.dismiss(toastId); } } // --- Invoker Commands --- /** * @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-*` attributes. * * @param {CommandEvent} event - The command event. * @returns {void} */ #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; /** @type {ToastOptions} */ const options = {}; const suffix = command.slice("--toast-".length); if (VARIANTS.includes(suffix)) { options.variant = /** @type {ToastOptions["variant"]} */ (suffix); } if (source instanceof HTMLElement) { const { title, description, variant, duration, closeButton } = source.dataset; if (title) options.title = title; if (description) options.description = description; if (!options.variant && variant && VARIANTS.includes(variant)) { options.variant = /** @type {ToastOptions["variant"]} */ (variant); } if (duration !== undefined) { const parsed = Utils.parseValue(duration); if (typeof parsed === "number") options.duration = parsed; } if (closeButton !== undefined) options.closeButton = closeButton !== "false"; } this.addToast(options); } // --- Toast construction --- /** * @description Builds a toast `<li>`: status icon, title/description, and the * optional action and close buttons. Text is set via `textContent`. * * @param {ToastOptions} options - Toast content and behavior. * @param {string} id - The generated toast id. * @returns {HTMLLIElement} The toast element (not yet inserted). */ #buildToast(options, id) { const toast = document.createElement("li"); toast.className = "toaster__toast"; toast.dataset.toastId = id; toast.tabIndex = 0; if (options.variant) toast.dataset.variant = options.variant; if (options.variant) { const icon = document.createElement("span"); icon.className = "toaster__icon"; icon.setAttribute("aria-hidden", "true"); icon.innerHTML = ICONS[options.variant]; toast.append(icon); } const content = document.createElement("div"); content.className = "toaster__content"; if (options.title) { const title = document.createElement("div"); title.className = "toaster__title"; title.textContent = options.title; content.append(title); } if (options.description) { const description = document.createElement("div"); description.className = "toaster__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.className = "button toaster__action"; button.dataset.size = "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.className = "button toaster__close"; close.dataset.variant = "ghost"; close.dataset.size = "icon-sm"; close.setAttribute("aria-label", "Close notification"); close.innerHTML = ICONS.close; close.addEventListener("click", () => this.dismiss(id)); toast.append(close); } return toast; } // --- Stack math --- /** * @description Recomputes the stack: measures every live toast's natural * height in one batch, then writes the CSS custom properties `_toaster.css` * reads (`--toasts-before`, `--offset`, `--initial-height`, * `--front-toast-height`) plus `data-front`, `data-visible`, and z-index. * DOM order is chronological; the last child is the front (newest) toast. * * @returns {void} */ #reindex() { const toasts = this.#toasts().filter((toast) => toast.dataset.removed !== "true"); const count = toasts.length; // Batch-measure with heights unclamped (inline style beats the collapsed // block-size rule), then restore — one layout pass, no visible change. for (const toast of toasts) toast.style.blockSize = "auto"; const heights = toasts.map((toast) => toast.offsetHeight); for (const toast of toasts) toast.style.removeProperty("block-size"); let heightsBefore = 0; for (let i = count - 1; i >= 0; i--) { const toast = toasts[i]; const stackIndex = count - 1 - i; // 0 = front (newest, last in DOM) toast.dataset.front = String(stackIndex === 0); toast.dataset.visible = String(stackIndex < VISIBLE_TOASTS); toast.style.zIndex = String(count - stackIndex); toast.style.setProperty("--toasts-before", String(stackIndex)); toast.style.setProperty("--initial-height", `${heights[i]}px`); toast.style.setProperty( "--offset", `calc(${heightsBefore}px + var(--toaster-gap) * ${stackIndex})`, ); heightsBefore += heights[i]; } if (count > 0) { this.style.setProperty("--front-toast-height", `${heights[count - 1]}px`); } } /** * @returns {HTMLOListElement} The toast list (created in `connectedCallback`). */ #list() { let list = this.querySelector(".toaster__list"); if (!(list instanceof HTMLOListElement)) { list = document.createElement("ol"); list.className = "toaster__list"; this.append(list); } return /** @type {HTMLOListElement} */ (list); } /** * @returns {HTMLElement[]} All toast elements, oldest first (DOM order). */ #toasts() { return Array.from(this.#list().children) .filter((child) => child instanceof HTMLElement) .filter((child) => child.classList.contains("toaster__toast")); } // --- Expand / collapse --- /** * @description Expands or collapses the stack. Expanding pauses every * auto-dismiss timer (matching Sonner); collapsing resumes them. * * @param {boolean} expanded - Whether the stack is expanded. * @returns {void} */ #setExpanded(expanded) { if (expanded) window.clearTimeout(this.#collapseTimer); if ((this.dataset.expanded === "true") === expanded) return; this.dataset.expanded = String(expanded); if (expanded) { this.#pauseTimers(); } else { this.#resumeTimers(); } } /** * @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. * * @returns {void} */ #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); } // --- Timers --- /** * @returns {boolean} Whether timers should not be running right now. */ get #paused() { return this.dataset.expanded === "true" || document.hidden; } /** * @description Registers a toast's auto-dismiss timer and schedules it unless * the stack is currently paused (expanded or hidden tab). * * @param {string} id - Toast id. * @param {number} duration - Lifetime in ms; `Infinity` skips the timer. * @returns {void} */ #startTimer(id, duration) { if (duration === Infinity || Number.isNaN(duration)) return; /** @type {ToastTimer} */ const timer = { remaining: duration, startedAt: 0, timeoutId: 0 }; this.#timers.set(id, timer); if (!this.#paused) this.#schedule(id, timer); } /** * @description Arms a timer's timeout for its remaining lifetime. * * @param {string} id - Toast id. * @param {ToastTimer} timer - The timer record. * @returns {void} */ #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. * * @returns {void} */ #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). * * @returns {void} */ #resumeTimers() { if (this.#paused) return; for (const [id, timer] of this.#timers) { if (!timer.timeoutId) this.#schedule(id, timer); } } // --- Region show/hide --- /** * @description Puts the region on the top layer if it isn't already. * * @returns {void} */ #showRegion() { try { if (!this.matches(":popover-open, .\\:popover-open")) this.showPopover(); } catch { // Older engines without the Popover API: the region stays a fixed-position // element, which still renders (just not on the top layer). } } /** * @description Removes the region from the top layer. * * @returns {void} */ #hideRegion() { try { if (this.matches(":popover-open, .\\: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 {HTMLElement} toast - The toast marked `data-removed="true"`. * @returns {void} */ #finalizeRemoval(toast) { let done = false; const remove = () => { if (done) return; done = true; toast.remove(); if (this.#toasts().length === 0) { this.dataset.expanded = "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 --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)); }}// --- Imperative API ---/** * @description Resolves a target region from an id, element, or the document. * * @param {string|Element} [region] - Region id or element. * @returns {ToastRegion|null} 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("toast-region"); if (node instanceof ToastRegion) return node; console.warn( 'Toaster: no <toast-region> found. Add `<toast-region class="toaster" popover="manual"></toast-region>` to the page.', ); return null;}/** * @description Normalizes the `toast("message")` string shorthand. * * @param {ToastOptions|string} [options] - Options object or title string. * @returns {ToastOptions} The options object. * @private */function toOptions(options) { if (typeof options === "string") return { title: options }; return options ?? {};}/** * @namespace Toaster * @description Imperative toast API. Requires a `<toast-region>` in the page — * the region is never auto-created (HTML-first, like every Zazz component). * * @property {(options?: ToastOptions|string) => string|null} toast - Shows a toast; returns its id. * @property {(message: string, options?: ToastOptions) => string|null} success - Success shorthand. * @property {(message: string, options?: ToastOptions) => string|null} info - Info shorthand. * @property {(message: string, options?: ToastOptions) => string|null} warning - Warning shorthand. * @property {(message: string, options?: ToastOptions) => string|null} error - Destructive shorthand. * @property {(id?: string) => void} dismiss - Dismisses one toast, or all when omitted. */const Toaster = { /** * @description Shows a toast in the target (or first) region. * * @param {ToastOptions|string} [options] - Options object or title string. * @returns {string|null} 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 {string} message - Toast title. * @param {ToastOptions} [options] - Additional options. * @returns {string|null} 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 {string} message - Toast title. * @param {ToastOptions} [options] - Additional options. * @returns {string|null} 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 {string} message - Toast title. * @param {ToastOptions} [options] - Additional options. * @returns {string|null} 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 {string} message - Toast title. * @param {ToastOptions} [options] - Additional options. * @returns {string|null} 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 {string} [id] - Toast id returned by `toast()`. * @returns {void} */ dismiss(id) { for (const region of document.querySelectorAll("toast-region")) { if (region instanceof ToastRegion) region.dismiss(id); } },};// Register the element (guarded against double script loads)if (typeof window !== "undefined" && !customElements.get("toast-region")) { customElements.define("toast-region", ToastRegion);}// Attach to window for the documented public API, then export for module consumers.if (typeof window !== "undefined") { window.Toaster = Toaster; window.ToastRegion = ToastRegion;}export { Toaster, ToastRegion };/** * _toaster.css — Toaster (<toast-region class="toaster">, .toaster__toast) * * Stacked toast notifications. Collapsed-stack presentation (newest toast in * front, older toasts peeking behind, expand on hover) adapted from Sonner by * Emil Kowalski — https://sonner.emilkowal.ski (MIT). * * @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 props (--toasts-before, --offset, --initial-height, * --front-toast-height + inline z-index) — stack math from toaster.js * @tokens --toaster-* (@layer variables) * @see /docs/components/toaster */@layer variables { :root { /* surface */ --toaster-background: var(--popover); --toaster-foreground: var(--popover-foreground); --toaster-border: var(--border); --toaster-radius: var(--radius-md); --toaster-shadow: var(--shadow-md); /* status accent — icon color; variants reassign */ --toaster-accent: var(--popover-foreground); /* metrics — mobile full width falls out of the min(); no media query */ --toaster-width: min(356px, calc(100vi - var(--gutters) * 2)); --toaster-inset: var(--gutters); --toaster-gap: var(--step-3_5); --toaster-padding: var(--step-4); --toaster-icon-size: var(--step-5); /* type */ --toaster-title-font-size: var(--font-size-sm); --toaster-title-font-weight: var(--font-weight-strong); --toaster-description-font-size: var(--font-size-sm); --toaster-description-color: var(--muted-foreground); /* stack — per-depth shrink of collapsed toasts */ --toaster-scale-step: 0.05; /* motion */ --toaster-transition-duration: calc(var(--default-transition-duration) * 1.5); --toaster-transition-timing-function: var(--default-transition-timing-function); --toaster-opacity--start: 0; --toaster-opacity--end: 1; }}@layer zazz.components { /* =========================================================================== TOASTER REGION — popover="manual" host (<toast-region class="toaster">) - Sits in the top layer while any toast is visible; toaster.js calls showPopover()/hidePopover(). It is a positioning shell, not a surface, so the shared [popover] chrome from _popover.css (reset + components layers) is neutralized here. The region does not animate — JS hides it only after the last toast's exit transition finishes. - --_lift is an inherited coordination var (like --_gap in _utilities.css): set per data-position on the region, read by every toast for the stack direction (-1 = toasts travel up from the bottom edge, 1 = down from top). =========================================================================== */ .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(--toaster-inset); inset-inline-end: var(--toaster-inset); inline-size: var(--toaster-width); --_lift: -1; /* Own snapshot group for page view transitions — frozen (never animated) in _view-transitions.css, so navigations can't cross-fade or repaint an active stack. One region per page: duplicate view-transition-names would make the browser skip the whole transition. */ view-transition-name: toaster; } .toaster:where(:popover-open, .\:popover-open) { display: block; } /* Positions — logical, so RTL flips for free. Default is bottom-end. */ .toaster[data-position^="top"] { inset-block-start: var(--toaster-inset); inset-block-end: auto; --_lift: 1; } .toaster[data-position$="start"] { inset-inline-start: var(--toaster-inset); inset-inline-end: auto; } .toaster[data-position$="center"] { inset-inline-start: calc(50vi - var(--toaster-width) / 2); inset-inline-end: auto; } .toaster__list { list-style: none; margin: 0; padding: 0; } /* =========================================================================== TOAST — absolutely positioned against the region's edge; all placement is transform-based so enter/exit/stack shifts share one transition. toaster.js maintains --toasts-before, --offset (cumulative expanded offset), --initial-height, data-front, data-visible, data-removed, and inline z-index. =========================================================================== */ .toaster__toast { position: absolute; inset-inline: 0; inset-block-end: 0; display: flex; align-items: center; gap: var(--step-2); inline-size: 100%; padding: var(--toaster-padding); color: var(--toaster-foreground); background-color: var(--toaster-background); border: 1px solid var(--toaster-border); border-radius: var(--toaster-radius); box-shadow: var(--toaster-shadow); overflow-wrap: anywhere; --_y: translateY(calc(var(--_lift) * var(--offset, 0px))); transform: var(--_y); opacity: var(--toaster-opacity--end); transition: transform var(--toaster-transition-duration) var(--toaster-transition-timing-function), opacity var(--toaster-transition-duration) var(--toaster-transition-timing-function), block-size var(--toaster-transition-duration) var(--toaster-transition-timing-function); /* Newly inserted toasts slide in from the region's edge (the region is already :popover-open — JS shows it before appending). */ @starting-style { opacity: var(--toaster-opacity--start); transform: translateY(calc(var(--_lift) * -100%)); } } .toaster[data-position^="top"] .toaster__toast { inset-block-end: auto; inset-block-start: 0; } /* Content crossfades when the stack collapses/expands. */ .toaster__toast > * { transition: opacity var(--toaster-transition-duration) var(--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. =========================================================================== */ .toaster:not([data-expanded="true"]) .toaster__toast:not([data-front="true"], [data-removed="true"]) { --_y: translateY(calc(var(--_lift) * var(--toaster-gap) * var(--toasts-before, 0))) scale(calc(1 - var(--toaster-scale-step) * var(--toasts-before, 0))); block-size: var(--front-toast-height); } .toaster:not([data-expanded="true"]) .toaster__toast:not([data-front="true"], [data-removed="true"]) > * { 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). =========================================================================== */ .toaster[data-expanded="true"] .toaster__toast { block-size: var(--initial-height, auto); } .toaster[data-expanded="true"] .toaster__toast::after { content: ""; position: absolute; inset-inline: 0; inset-block-end: 100%; block-size: calc(var(--toaster-gap) + 1px); } .toaster[data-position^="top"][data-expanded="true"] .toaster__toast::after { inset-block-end: auto; inset-block-start: 100%; } /* Beyond the visible limit — kept in the DOM until dismissed, but hidden. */ .toaster__toast[data-visible="false"] { 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: hiding it from hit-testing would fire mouseleave on the region and collapse an expanded stack mid-dismiss. toaster.js already ignores interactions with removed toasts. */ .toaster__toast[data-removed="true"] { opacity: var(--toaster-opacity--start); --_y: translateY(calc(var(--_lift) * var(--offset, 0px) + var(--_lift) * -100%)); } .toaster:not([data-expanded="true"]) .toaster__toast[data-removed="true"]:not([data-front="true"]) { --_y: translateY(calc(var(--_lift) * var(--toaster-gap) * var(--toasts-before, 0))) scale(calc(1 - var(--toaster-scale-step) * var(--toasts-before, 0))); } /* =========================================================================== TOAST PARTS =========================================================================== */ .toaster__icon { flex-shrink: 0; inline-size: var(--toaster-icon-size); block-size: var(--toaster-icon-size); color: var(--toaster-accent); } .toaster__icon > svg { display: block; inline-size: 100%; block-size: 100%; } .toaster__content { flex: 1; min-inline-size: 0; display: flex; flex-direction: column; gap: var(--step-1); } .toaster__title { font-size: var(--toaster-title-font-size); font-weight: var(--toaster-title-font-weight); line-height: var(--leading-sm); } .toaster__description { font-size: var(--toaster-description-font-size); line-height: var(--leading-sm); color: var(--toaster-description-color); } .toaster__action, .toaster__close { flex-shrink: 0; } /* =========================================================================== VARIANTS — reassign the accent token only =========================================================================== */ .toaster__toast[data-variant="success"] { --toaster-accent: var(--success); } .toaster__toast[data-variant="info"] { --toaster-accent: var(--info); } .toaster__toast[data-variant="warning"] { --toaster-accent: var(--warning); } .toaster__toast[data-variant="destructive"] { --toaster-accent: var(--destructive); }}The preview loads the required scripts automatically; open the JS tab to inspect.
API
| Attribute | Where | Values |
|---|---|---|
command | trigger | --toast, --toast-success, --toast-info, --toast-warning, --toast-destructive |
commandfor | trigger | id of the <toast-region> |
data-title | trigger | toast heading |
data-description | trigger | supporting copy |
data-variant | trigger | success, info, warning, destructive (alternative to command suffix) |
data-duration | trigger | lifetime in ms; Infinity persists until dismissed |
data-close-button | trigger | false hides the close button |
data-position | <toast-region> | top-start, top-center, top-end, bottom-start, bottom-center, bottom-end (default) |
popover | <toast-region> | manual — required; keeps the region on the top layer without light dismiss |
JavaScript
// Show a toast (returns an id)
const id = Toaster.toast({
title: "Event created",
description: "Monday, July 13 at 9:00 AM",
variant: "success", // "success" | "info" | "warning" | "destructive"
duration: 4000, // ms; Infinity persists
action: { label: "Undo", onClick: (event) => {} },
closeButton: true,
region: "my-toaster", // region id or element; default: first <toast-region>
});
// Shorthands
Toaster.success("Changes saved");
Toaster.info("Update available");
Toaster.warning("Storage almost full");
Toaster.error("Something went wrong");
// Dismiss one toast, or all
Toaster.dismiss(id);
Toaster.dismiss();An action's onClick may call event.preventDefault() to keep the toast open.
The region is required in markup — it is never auto-created.