Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Toaster

Stacked toast notifications fired by a command.

Installation

pnpm dlx zazz-ui add toaster
npx zazz-ui add toaster
yarn dlx zazz-ui add toaster
bunx zazz-ui add toaster

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

Attributes

toaster attributes
AttributeKindValuesNotes
data-ui="toaster"Identityfree valueIdentity token in data-ui.
<ui-toaster>Tag formfree valueTag form; styled and, when scripted, registered under this name.
data-toaster-variantPresetdestructive info success warningPreset: a named bundle of values.
data-toaster-slotSlotaction close content description icon list title toastSlot: a part inside the primitive.
data-toaster-stateStateexpanded front removed visibleState the kit's script writes; read it in selectors, never author it.
data-toaster-close-buttonConfigfree valueConfiguration the kit's script reads.
data-toaster-descriptionConfigfree valueConfiguration the kit's script reads.
data-toaster-durationConfigfree valueConfiguration the kit's script reads.
data-toaster-idConfigfree valueConfiguration the kit's script reads.
data-toaster-positionConfigfree valueConfiguration the kit's script reads.
data-toaster-titleConfigfree valueConfiguration the kit's script reads.

Requires: popover, button.

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.

toaster hooks
HookDefaultState
--ui-toaster-accentvar(--color-popover-foreground)—
--ui-toaster-bgvar(--color-popover)—
--ui-toaster-border-colorvar(--color-border)—
--ui-toaster-description-font-sizevar(--font-size-sm)—
--ui-toaster-description-textvar(--color-muted-foreground)—
--ui-toaster-gapcalc(var(--spacing) * 3.5)—
--ui-toaster-icon-sizecalc(var(--spacing) * 5)—
--ui-toaster-insetvar(--gutters)—
--ui-toaster-opacity--end1end
--ui-toaster-opacity--start0start
--ui-toaster-pcalc(var(--spacing) * 4)—
--ui-toaster-roundedvar(--radius-md)—
--ui-toaster-scale-step0.05—
--ui-toaster-shadowvar(--shadow-md)—
--ui-toaster-textvar(--color-popover-foreground)—
--ui-toaster-title-font-sizevar(--font-size-sm)—
--ui-toaster-title-font-weightvar(--font-weight-strong)—
--ui-toaster-transition-durationcalc(var(--default-transition-duration) * 1.5)—
--ui-toaster-transition-timing-functionvar(--default-transition-timing-function)—
--ui-toaster-wmin(356px, calc(100vi - var(--gutters) * 2))—