Theme
On this page (11)
Component Skill — components/toast/component-skill.md

Native basis

popover API for top-layer rendering and non-modal behavior.

Web Platform APIs

Popover API (popover="manual")aria-live regions

Classes

.toast-container.toast.toast-description.toast-icon.toast-content.toast-text.toast-title.toast-close.toast-actions

Variants (data-variant)

destructiveAlternate background stylingsuccessColor variationwarningColor variationinfoColor variation

Data attributes

• data-position - values: bottom-right, bottom-left, top-right, top-left, top-center, bottom-center

• data-toast-close

• data-toast-action

Wiring conventions

• data-toast-close on any element inside - closes the component

• data-toast-action identifies an interactive action element

Notes

• The toast container should be a direct child of <body>

• Toasts use popover="manual" so they don't auto-dismiss on outside click

• The stacking order is newest on top (CSS flex-direction: column-reverse for bottom positions)

• Maximum visible toasts defaults to 3 - older toasts are dismissed

• Swipe-to-dismiss can be added with touch event handling but is not required for MVP

• For forms, show success/error toasts after submission rather than inline messages

• The toast() API is imperative - call it from any event handler

§Default

Basic toast with title and description via df$.shadcn.toast.show().

§Variants

Semantic variants add a colored border and icon. Use df$.shadcn.toast.success(), df$.shadcn.toast.warning(), df$.shadcn.toast.info(), or df$.shadcn.toast.error().

§With Description

Pass a description for a secondary line of detail below the title.

§With Action

Add an action object with label and onClick to render an action button inside the toast.

§Stacked toasts

configure({ stack: 'pile' }): the newest toast stays in front and the older ones wait behind it as the Stacks sheets (shapes.css stack-top - tapered sheets) with a +n badge; hover or focus the pile and it fans out into a list, leave it and it folds back. Send a few - then switch to a list for comparison.

§Animations

animation: { in, out, direction } plays any df$.anim pair on the toast - slide from the edge it lives at, pop, flip, blur, zoom, wipe. Dismiss (× or the timer) runs the exit.

§Aura

aura: true (or a style: 'rainbow', 'gold', 'holo', 'dual') turns the toast into a ring of animated light (shapes.css .aura) - for the one notification that must not be missed.

§Size & Density

size scales the width envelope (sm / md / lg, md matches the unsized default), density the padding and content gap (compact / comfortable / spacious) - pass either option to show() and it lands as the matching data-* attribute on the toast.

§States

The named-state API is bound to the region container (#toast-container):

  • default - no visible toasts: setState('default') dismisses everything (the same path as window.\df$.shadcn.toast.dismiss()); getState().config.count reports the live number of visible toasts

The region element carries data-state-demo; bun run screenshots drives it via el.api.setState(name).

Machine contract - verified against toast.schema.json by bun run verify:

StateTypeValuesDefaultDescription

§CSS view file

/* -- Toast component ------------------------------------------- */
@layer components {
  /* -- Container ------------------------------------------------ */
  .toast-container {
    position: fixed;
    z-index: 100;
    display: flex;
    flex-direction: column-reverse;
    gap: 0.5rem;
    padding: 1rem;
    max-height: 100vh;
    pointer-events: none;
    &[data-position="bottom-right"] { bottom: 0; right: 0; }
    &[data-position="bottom-left"]  { bottom: 0; left: 0; }
    &[data-position="top-right"]    { top: 0; right: 0; flex-direction: column; }
    &[data-position="top-left"]     { top: 0; left: 0; flex-direction: column; }
    &[data-position="top-center"]   { top: 0; left: 50%; transform: translateX(-50%); flex-direction: column; align-items: center; }
    &[data-position="bottom-center"]{ bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; }
  }
  /* -- Toast ---------------------------------------------------- */
  .toast {
    /* Top layer (popover="manual") bypasses the container's flex layout - the
       UA sheet would CENTER every toast (inset:0 + margin:auto). Pin each toast
       to its container's corner instead and stack via --toast-stack, which the
       component JS measures (toast.ts stackToasts) since heights vary. */
    position: fixed;
    inset: auto;
    margin: 0;
    /* the UA popover sheet clips (overflow: auto) - the pile badge sits on the edge */
    overflow: visible;
    background-color: var(--popover);
    color: var(--popover-foreground);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    padding: 1rem;
    min-width: 20rem;
    max-width: 26rem;
    box-shadow: 0 4px 16px oklch(0 0 0 / 0.12);
    pointer-events: auto;
    opacity: 0;
    transform: translateY(0.5rem);
    transition: opacity 200ms ease, transform 200ms ease;
    &:popover-open {
      opacity: 1;
      transform: translateY(0);
    }
    /* -- Corner placement (top layer - see note above) ------------
       --toast-stack: measured px offset of the stack below this toast;
       the first toast is 0, so default bottom-right lands at the edge. */
    [data-position="bottom-right"] & { bottom: calc(1rem + var(--toast-stack, 0px)); right: 1rem; }
    [data-position="bottom-left"] & { bottom: calc(1rem + var(--toast-stack, 0px)); left: 1rem; }
    [data-position="top-right"] & { top: calc(1rem + var(--toast-stack, 0px)); right: 1rem; }
    [data-position="top-left"] & { top: calc(1rem + var(--toast-stack, 0px)); left: 1rem; }
    [data-position="top-center"] & { top: calc(1rem + var(--toast-stack, 0px)); left: 50%; translate: -50% 0; }
    [data-position="bottom-center"] & { bottom: calc(1rem + var(--toast-stack, 0px)); left: 50%; translate: -50% 0; }
    /* -- Sizes + Density ------------------------------------------
       data-size scales the toast's width envelope (sm/md/lg, md == the
       unsized default); data-density is the whitespace policy - padding and
       the content gap at the shared 0.75 / 1 / 1.25 ratio, comfortable ==
       the unsized default. Both live on the .toast element itself. */
    &[data-size="sm"] { min-width: 16rem; max-width: 20rem; }
    &[data-size="md"] { min-width: 20rem; max-width: 26rem; }
    &[data-size="lg"] { min-width: 24rem; max-width: 32rem; }
    &[data-density="compact"]    { padding: 0.75rem; & .toast-content { gap: 0.5rem; } }
    &[data-density="comfortable"] { padding: 1rem; }
    &[data-density="spacious"]   { padding: 1.25rem; & .toast-content { gap: 1rem; } }
    /* -- Variants ------------------------------------------------- */
    &[data-variant="destructive"] {
      background-color: var(--destructive);
      color: var(--destructive-foreground);
      border-color: var(--destructive);
      & .toast-description {
        color: var(--destructive-foreground);
        opacity: 0.85;
      }
    }
    /* -- Semantic variants (border + icon color) ----------------- */
    &[data-variant="success"] {
      border-color: oklch(0.65 0.2 145);
      & .toast-icon { color: oklch(0.65 0.2 145); }
    }
    &[data-variant="warning"] {
      border-color: oklch(0.75 0.18 75);
      & .toast-icon { color: oklch(0.75 0.18 75); }
    }
    &[data-variant="info"] {
      border-color: oklch(0.6 0.15 250);
      & .toast-icon { color: oklch(0.6 0.15 250); }
    }
  }
  @starting-style {
    .toast:popover-open {
      opacity: 0;
      transform: translateY(0.5rem);
    }
  }
  /* -- Named animations (options.animation) ------------------------
     df$.anim plays the entrance / exit; the CSS fade stands down. */
  .toast[data-anim] {
    transition: bottom 220ms ease, top 220ms ease, scale 220ms ease, opacity 220ms ease;
    &:popover-open { opacity: 1; transform: none; }
  }
  @starting-style {
    .toast[data-anim]:popover-open { opacity: 1; transform: none; }
  }
  /* -- Pile (df$.shadcn.toast.configure({ stack: 'pile' })) ----------
     The newest toast is in front and wears the shapes.css stack-* sheets
     (added by toast.js); the older ones wait behind it, hidden, and fan out
     into a list while the pointer or focus is inside the pile. A "+n" badge
     counts the hidden ones. */
  .toast-container[data-stack="pile"] .toast {
    transition: opacity 200ms ease, transform 200ms ease, bottom 220ms cubic-bezier(0.2, 0.8, 0.2, 1), top 220ms cubic-bezier(0.2, 0.8, 0.2, 1), scale 220ms ease;
  }
  .toast[data-piled] {
    opacity: 0 !important;
    scale: 0.94;
    pointer-events: none;
  }
  .toast[data-more]:not([data-more="0"])::after {
    content: "+" attr(data-more);
    position: absolute;
    inset-block-start: -0.5rem;
    inset-inline-start: -0.5rem;
    min-width: 1.5rem;
    height: 1.5rem;
    padding-inline: 0.375rem;
    border-radius: 9999px;
    background-color: var(--primary);
    color: var(--primary-foreground);
    font-size: 0.75rem;
    font-weight: 600;
    line-height: 1.5rem;
    text-align: center;
    box-shadow: var(--shadow-sm);
  }
  .toast-container[data-position^="top"] .toast[data-more]::after { inset-block-start: auto; inset-block-end: -0.5rem; }
  /* -- Aura (options.aura) ------------------------------------------
     The toast is the ring of light (shapes.css .aura), its content sits on
     an inner surface with the toast's own colors and padding. */
  .toast[data-aura] {
    --shape-round: var(--radius-lg);
    border: 0;
    background-color: transparent;
    box-shadow: 0 8px 28px -6px color-mix(in oklch, var(--foreground) 30%, transparent);
  }
  .toast-surface {
    padding: 1rem;
    border-radius: var(--radius-lg);
    background-color: var(--popover);
    color: var(--popover-foreground);
    .toast[data-variant="destructive"] > & { background-color: var(--destructive); color: var(--destructive-foreground); }
  }
  /* -- Inner elements ------------------------------------------- */
  .toast-content {
    display: flex;
    align-items: flex-start;
    gap: 0.75rem;
  }
  .toast-text {
    flex: 1;
    min-width: 0;
  }
  .toast-title {
    font-size: 0.875rem;
    font-weight: 500;
    margin: 0;
    line-height: 1.4;
  }
  .toast-description {
    font-size: 0.8125rem;
    color: var(--muted-foreground);
    margin: 0.125rem 0 0;
    line-height: 1.5;
  }
  .toast-close {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 1.75rem;
    height: 1.75rem;
    border: none;
    background: transparent;
    color: var(--muted-foreground);
    cursor: pointer;
    flex-shrink: 0;
    border-radius: calc(var(--radius) * 0.5);
    transition: color 150ms, background 150ms;
    &:hover {
      color: var(--foreground);
      background: var(--accent);
    }
  }
  .toast-icon {
    /* explicit size: SVGs without width/height render at a ~300px intrinsic
       default, which blew up the whole toast */
    flex-shrink: 0;
    width: 1rem;
    height: 1rem;
    margin-top: 0.125rem;
  }
  .toast-actions {
    display: flex;
    gap: 0.5rem;
    margin-top: 0.75rem;
  }
}
/* Accessibility: suppress motion for users who request it (REQUIRED for all
   components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of
   `none` keeps transitionend/animationend (and discrete display flips)
   firing so JS state machines that await them keep working. */
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .toast-container,
    .toast-container *,
    .toast-container::before,
    .toast-container::after,
    .toast-container *::before,
    .toast-container *::after,
    .toast-container::backdrop,
    .toast,
    .toast *,
    .toast::before,
    .toast::after,
    .toast *::before,
    .toast *::after,
    .toast::backdrop,
    .toast-description,
    .toast-description *,
    .toast-description::before,
    .toast-description::after,
    .toast-description *::before,
    .toast-description *::after,
    .toast-description::backdrop,
    .toast-icon,
    .toast-icon *,
    .toast-icon::before,
    .toast-icon::after,
    .toast-icon *::before,
    .toast-icon *::after,
    .toast-icon::backdrop,
    .toast-content,
    .toast-content *,
    .toast-content::before,
    .toast-content::after,
    .toast-content *::before,
    .toast-content *::after,
    .toast-content::backdrop,
    .toast-text,
    .toast-text *,
    .toast-text::before,
    .toast-text::after,
    .toast-text *::before,
    .toast-text *::after,
    .toast-text::backdrop,
    .toast-title,
    .toast-title *,
    .toast-title::before,
    .toast-title::after,
    .toast-title *::before,
    .toast-title *::after,
    .toast-title::backdrop,
    .toast-close,
    .toast-close *,
    .toast-close::before,
    .toast-close::after,
    .toast-close *::before,
    .toast-close *::after,
    .toast-close::backdrop,
    .toast-actions,
    .toast-actions *,
    .toast-actions::before,
    .toast-actions::after,
    .toast-actions *::before,
    .toast-actions *::after,
    .toast-actions::backdrop {
      transition-duration: 0.01ms !important;
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
    }
  }
}

§JavaScript view file

Imperative \df$.shadcn.toast.show() API with auto-dismiss, stacking (max 3 visible), variant icons, and action buttons. Container is auto-created if not present in the DOM.

// -- Toast -----------------------------------------------------
// Programmatic toast notification API.
// Exposes df$.toast with show/success/warning/info/error/dismiss
// (AGENTS.md "No window globals" - everything lives under the one namespace).
// Named-state API (AGENTS.md "State API") bound to the region container:
// its observable state is which toasts are visible, so 'default' clears the
// region (same code path as toast.dismiss()) and getState() reports
// the live toast count.
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
// defussQuery: the callable runtime for toast mounting/lifecycle (§3 of the
// morph integration plan - mount via query .append(), dismiss via .remove()).
// anim: the shared engine (df$.anim) - toasts can enter / leave with any of
// its named animations (options.animation)
import { defussGlobals, defussQuery, anim } from '../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const toastStates = ['default'];
/**
 * UI side of setState: 'default' dismisses every visible toast, returning
 * the region to its authored (empty) state.
 */
function triggerStateChange(container, stateName, _config) {
  if (stateName !== 'default') return;
  container.querySelectorAll('.toast').forEach((el) => toastDismiss(el));
}
/** Registry-level API; pass the container explicitly. Unknown names throw. */
export const toastApi = {
  setState(container, stateName, config = {}) {
    if (!toastStates.includes(stateName)) {
      throw new Error(`toast: unknown state "${stateName}" (supported: ${toastStates.join(', ')})`);
    }
    triggerStateChange(container, stateName, config);
    // state lives on the ELEMENT, not the module (one region per page, but
    // SPA navigation may replace it)
    container.dataset.stateName = stateName;
    container._stateConfig = config;
  },
  getState(container) {
    return {
      name: container.dataset.stateName || 'default',
      // live count - reflects df$.toast.show() and auto-dismiss, not just setState
      config: { ...container._stateConfig, count: container.querySelectorAll('.toast').length },
    };
  },
};
df$.toastApi = toastApi;
df$.toastStates = toastStates;
const DURATION = 4000;
const MAX_VISIBLE = 3;
// Per-toast callbacks live in a WeakMap so the container's ONE delegated
// click listener can find them - dynamically created toasts never get their
// own listeners, so no per-element cleanup is ever needed.
const toastCallbacks = new WeakMap();
let toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
  toastContainer = document.createElement('div');
  toastContainer.id = 'toast-container';
  toastContainer.className = 'toast-container';
  toastContainer.setAttribute('aria-label', 'Notifications');
  toastContainer.setAttribute('data-position', 'bottom-right');
  dfDollar(document.body).append(toastContainer); // query's exact mount op (§5.1)
}
/** Stack offset for each visible toast: toasts render in the top layer
 * (popover="manual"), so the container's flex layout can't position them —
 * CSS pins each to the corner and reads --toast-stack, which we measure here
 * (px of newer toasts below it + 0.5rem gaps, matching the container gap). */
const stackToasts = (container) => {
  const toasts = [...container.querySelectorAll('.toast:not([data-leaving])')];
  // pile (configure({ stack: 'pile' })): only the newest shows, the others
  // sit behind it - drawn as the shapes.css stack-* sheets on the newest -
  // until the pointer / focus enters the pile, which fans it out as a list
  const piled = container.dataset.stack === 'pile' && !container.hasAttribute('data-expanded') && toasts.length > 1;
  const top = (container.dataset.position || '').startsWith('top');
  const sheets = top ? 'stack-bottom' : 'stack-top';
  let offset = 0;
  // newest nearest the corner in a pile / expanded pile, oldest nearest in a list
  const order = container.dataset.stack === 'pile' ? [...toasts].reverse() : toasts;
  order.forEach((t, i) => {
    const newest = i === 0;
    t.style.setProperty('--toast-stack', `${piled ? 0 : offset}px`);
    t.toggleAttribute('data-piled', piled && !newest);
    t.classList.toggle(sheets, piled && newest);
    if (piled && newest) t.dataset.more = String(toasts.length - 1);
    else delete t.dataset.more;
    if (!piled) offset += t.getBoundingClientRect().height + 8;
  });
};
const toastDismiss = (el, callback) => {
  if (!el || !el.parentNode || el.hasAttribute('data-leaving')) return;
  const container = el.parentNode;
  const out = el._animation?.out;
  if (out && anim[out]) {
    el.setAttribute('data-leaving', '');
    stackToasts(container);
    anim[out].play(el, { duration: el._animation.duration ?? 350, direction: el._animation.direction }).finished.then(() => {
      try { el.hidePopover(); } catch {}
      dfDollar(el).remove();
      stackToasts(container);
      if (callback) callback();
    });
    return;
  }
  el.animate(
    [{ opacity: 1, transform: 'translateY(0)' }, { opacity: 0, transform: 'translateY(0.5rem)' }],
    { duration: 200, easing: 'ease', fill: 'forwards' }
  // dismissal through query's exact .remove() - AFTER the exit animation and
  // popover teardown (§5.2: removal still disposes owned state first)
  ).finished.then(() => { try { el.hidePopover(); } catch {} dfDollar(el).remove(); stackToasts(container); if (callback) callback(); });
};
const toastCreate = (options) => {
  const o = typeof options === 'string' ? { title: options } : options;
  const { title, description, variant, action, onDismiss, size, density, animation, aura } = o;
  const duration = o.duration != null ? o.duration : DURATION;
  const el = document.createElement('div'); el.className = 'toast';
  el.setAttribute('role', variant === 'destructive' ? 'alert' : 'status');
  el.setAttribute('aria-live', variant === 'destructive' ? 'assertive' : 'polite');
  el.setAttribute('aria-atomic', 'true'); el.setAttribute('popover', 'manual');
  if (variant) el.setAttribute('data-variant', variant);
  // size/density are pure CSS axes (width envelope / whitespace policy) —
  // forward them as data attributes the component stylesheet understands
  if (size) el.setAttribute('data-size', size);
  if (density) el.setAttribute('data-density', density);
  const icons = {
    success: '<svg class="toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
    warning: '<svg class="toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>',
    info: '<svg class="toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
    destructive: '<svg class="toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>'
  };
  // Build toast DOM (§5.1 boundary: static icon markup rides query .html(),
  // user-supplied title/description stay literal text via .text() - never an
  // HTML sink, §5.2; insertions go through query's exact .append() ops)
  const contentEl = document.createElement('div');
  contentEl.className = 'toast-content';
  if (variant && icons[variant]) {
    // the query factory parses a leading-`<` string as markup (§5.2) - the
    // trusted static icon flows in through one query append op
    dfDollar(contentEl).append(dfDollar(icons[variant]));
  }
  const textDiv = document.createElement('div');
  textDiv.className = 'toast-text';
  if (title) { const p = document.createElement('p'); p.className = 'toast-title'; dfDollar(p).text(title); dfDollar(textDiv).append(p); }
  if (description) { const p = document.createElement('p'); p.className = 'toast-description'; dfDollar(p).text(description); dfDollar(textDiv).append(p); }
  dfDollar(contentEl).append(textDiv);
  const closeBtn = document.createElement('button');
  closeBtn.className = 'toast-close'; closeBtn.setAttribute('aria-label', 'Dismiss'); closeBtn.dataset.toastClose = '';
  dfDollar(closeBtn).html('<svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>');
  dfDollar(contentEl).append(closeBtn);
  // aura: the toast becomes the ring of light (shapes.css .aura), its
  // content sits on an inner surface
  let host = el;
  if (aura) {
    const style = aura === true ? '' : String(aura);
    el.classList.add('aura', 'aura-md');
    if (style) el.classList.add(`aura-${style}`);
    el.dataset.aura = style || 'default';
    host = document.createElement('div');
    host.className = 'toast-surface';
    dfDollar(el).append(host);
  }
  dfDollar(host).append(contentEl);
  if (action) {
    const actionsDiv = document.createElement('div'); actionsDiv.className = 'toast-actions';
    const actionBtn = document.createElement('button'); actionBtn.className = 'btn';
    actionBtn.setAttribute('data-variant', 'outline'); actionBtn.setAttribute('data-size', 'sm'); actionBtn.dataset.toastAction = '';
    dfDollar(actionBtn).text(action.label); // literal action label (§5.2)
    dfDollar(actionsDiv).append(actionBtn); dfDollar(host).append(actionsDiv);
  }
  // mount through query's exact .append() - the node itself is inserted
  // (identity + delegated listeners kept, §3 toast row of the morph plan)
  // animation: { in, out, direction, duration } - names from df$.anim
  // (fadeIn, slideIn, popIn, zoomIn, flipIn, blurIn, …); the CSS entrance
  // stands down while a named one plays
  if (animation) {
    el._animation = typeof animation === 'string' ? { in: animation } : animation;
    el.dataset.anim = '';
  }
  dfDollar(toastContainer).append(el); el.showPopover();
  stackToasts(toastContainer);
  const inName = el._animation?.in;
  if (inName && anim[inName]) anim[inName].play(el, { duration: el._animation.duration ?? 450, direction: el._animation.direction });
  toastCallbacks.set(el, { onDismiss, action });
  if (duration !== Infinity) setTimeout(() => { toastDismiss(el, onDismiss); }, duration);
  const toasts = toastContainer.querySelectorAll('.toast');
  // a pile holds more (they are sheets, not screen space)
  if (toasts.length > (toastContainer.dataset.stack === 'pile' ? 6 : MAX_VISIBLE)) toastDismiss(toasts[0]);
  return el;
};
// Delegated wiring: close/action clicks on ANY toast (including ones created
// later) are handled by one listener on the container. Guarded per container
// with data-init and re-run by the MutationObserver, per the component
// lifecycle contract (AGENTS.md) - survives SPA navigation replacing the body.
function init() {
  document.querySelectorAll('#toast-container:not([data-init])').forEach((container) => {
    container.dataset.init = '';
    // bind-scope the api per region: `$('#toast-container').api.setState('default')`
    container.api = {
      setState: (stateName, config) => toastApi.setState(container, stateName, config),
      getState: () => toastApi.getState(container),
    };
    // a pile fans out while the pointer or focus is inside it
    const expand = (on) => {
      if (container.dataset.stack !== 'pile') return;
      clearTimeout(container._collapse);
      if (on) {
        if (!container.hasAttribute('data-expanded')) { container.setAttribute('data-expanded', ''); stackToasts(container); }
      } else {
        container._collapse = setTimeout(() => { container.removeAttribute('data-expanded'); stackToasts(container); }, 250);
      }
    };
    container.addEventListener('pointerover', (e) => { if (e.target.closest('.toast')) expand(true); });
    container.addEventListener('pointerout', (e) => { if (!e.relatedTarget?.closest?.('.toast')) expand(false); });
    container.addEventListener('focusin', () => expand(true));
    container.addEventListener('focusout', (e) => { if (!e.relatedTarget?.closest?.('.toast')) expand(false); });
    container.addEventListener('click', (e) => {
      const btn = e.target.closest('[data-toast-close],[data-toast-action]');
      if (!btn) return;
      const toast = btn.closest('.toast');
      if (!toast) return;
      const cb = toastCallbacks.get(toast) ?? {};
      if (btn.hasAttribute('data-toast-action')) { if (cb.action) cb.action.onClick(); toastDismiss(toast); }
      else toastDismiss(toast, cb.onDismiss);
    });
  });
}
init();
new MutationObserver(init).observe(document.body, { childList: true, subtree: true });
/** Region options: stack 'list' (default, every toast visible) or 'pile'
 * (the newest in front, the others as sheets behind it - hover / focus fans
 * them out); position = the corner (bottom-right, bottom-left, top-right,
 * top-left, top-center, bottom-center). */
const toastConfigure = (opts = {}) => {
  if (opts.stack) toastContainer.dataset.stack = opts.stack;
  if (opts.position) toastContainer.setAttribute('data-position', opts.position);
  stackToasts(toastContainer);
  return { stack: toastContainer.dataset.stack || 'list', position: toastContainer.dataset.position };
};
df$.toast = {
  configure: toastConfigure,
  show: toastCreate,
  success: (o) => toastCreate(Object.assign(typeof o === 'string' ? { title: o } : o, { variant: 'success' })),
  warning: (o) => toastCreate(Object.assign(typeof o === 'string' ? { title: o } : o, { variant: 'warning' })),
  info: (o) => toastCreate(Object.assign(typeof o === 'string' ? { title: o } : o, { variant: 'info' })),
  error: (o) => toastCreate(Object.assign(typeof o === 'string' ? { title: o } : o, { variant: 'destructive' })),
  dismiss: () => { toastContainer.querySelectorAll('.toast').forEach((el) => { toastDismiss(el); }); }
};

Comments, ideas or improvements? Edit this page's source on GitHub