Theme
Design your own
On this page (12)
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

§API

Generated from toast.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type ToastState = 'default' - setState(name, config) takes the config of the state it names.

StateDescription
default
The region as authored - setting it dismisses every visible toast.
Config fieldTypeDescription
count?numberreported by getState(): the toasts in the region now

Every element

MemberDescription
el.api.setState<S extends ToastState>(name: S, config?: ToastStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?ToastStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: ToastState; config: ToastStateConfigs[ToastState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: ToastState; config: ToastStateConfigs[ToastState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: ToastState; config: ToastStateConfigs[ToastState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: ToastState; config: ToastStateConfigs[ToastState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: ToastState; config: ToastStateConfigs[ToastState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.toastApi.setState<S extends ToastState>(el: HTMLElement, name: S, config?: ToastStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?ToastStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.toastApi.getState(el: HTMLElement): { name: ToastState; config: ToastStateConfigs[ToastState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: ToastState; config: ToastStateConfigs[ToastState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.toastApi.render(state: { name: ToastState; config: ToastStateConfigs[ToastState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: ToastState; config: ToastStateConfigs[ToastState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.toastApi.store(el: HTMLElement): Store<{ name: ToastState; config: ToastStateConfigs[ToastState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: ToastState; config: ToastStateConfigs[ToastState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.toastApi.commit<S extends ToastState>(el: HTMLElement, name: S, config?: ToastStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?ToastStateConfigs[S]its config
df$.shadcn.toastStates: ToastState[]The declared states, 'default' first: default.

df$.shadcn.toast

MemberDescription
configure(opts: ToastRegionOptions = {}): ToastRegionOptions
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).
ArgumentTypeDescription
optsToastRegionOptions = {}the options to change; omitted keys stay as they are

Returns ToastRegionOptions - the region's options now

show(options: string | ToastOptions): HTMLElement
Show a toast - a title string or { title, description, variant, duration, action ... }.
ArgumentTypeDescription
optionsstring | ToastOptionsthe title, or the toast options

Returns HTMLElement - the toast element (a manual popover in the region)

success(options: string | ToastOptions): HTMLElement
show() as a success toast.
ArgumentTypeDescription
optionsstring | ToastOptionsthe title, or the toast options (the variant is set for you)

Returns HTMLElement - the toast element

warning(options: string | ToastOptions): HTMLElement
show() as a warning toast.
ArgumentTypeDescription
optionsstring | ToastOptionsthe title, or the toast options (the variant is set for you)

Returns HTMLElement - the toast element

info(options: string | ToastOptions): HTMLElement
show() as an info toast.
ArgumentTypeDescription
optionsstring | ToastOptionsthe title, or the toast options (the variant is set for you)

Returns HTMLElement - the toast element

error(options: string | ToastOptions): HTMLElement
show() as an error (destructive) toast.
ArgumentTypeDescription
optionsstring | ToastOptionsthe title, or the toast options (the variant is set for you)

Returns HTMLElement - the toast element

dismiss(): void
Dismiss every toast.

Types

TypeDescription
ToastOptions
What show() takes (a plain string is the title).
FieldTypeDescription
title?stringthe bold first line
description?stringthe second line
variant?'success' | 'warning' | 'info' | 'destructive'the look and the icon; 'destructive' is announced assertively (role="alert")
duration?numberms until it dismisses itself (default 4000); Infinity keeps it until dismissed
action?{ label: string; onClick: () => void }one action button: its label and what a click does (the toast closes after)
onDismiss?() => voidcalled when the toast is dismissed by its close button or its timer
size?'sm' | 'md' | 'lg'the width envelope
density?'compact' | 'comfortable' | 'spacious'the whitespace policy
animation?string | { in?: string; out?: string; direction?: string; duration?: number }a df$.anim entrance (fadeIn, slideIn, popIn, ...), or { in, out, direction, duration } for both ways
aura?boolean | stringa ring of light around it (shapes.css .aura): true, or the aura style name
ToastRegionOptions
The toast region's options (configure() takes and returns them).
FieldTypeDescription
stack?'list' | 'pile''list': every toast visible; 'pile': the newest in front, the others as sheets behind it
position?'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'top-center' | 'bottom-center'the corner the toasts appear in

§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, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds
// against its code: a wrong one is a new type error and fails the build.
/** What show() takes (a plain string is the title). */
interface ToastOptions {
  /** the bold first line */
  title?: string;
  /** the second line */
  description?: string;
  /** the look and the icon; 'destructive' is announced assertively (role="alert") */
  variant?: 'success' | 'warning' | 'info' | 'destructive';
  /** ms until it dismisses itself (default 4000); Infinity keeps it until dismissed */
  duration?: number;
  /** one action button: its label and what a click does (the toast closes after) */
  action?: { label: string; onClick: () => void };
  /** called when the toast is dismissed by its close button or its timer */
  onDismiss?: () => void;
  /** the width envelope */
  size?: 'sm' | 'md' | 'lg';
  /** the whitespace policy */
  density?: 'compact' | 'comfortable' | 'spacious';
  /** a df$.anim entrance (fadeIn, slideIn, popIn, ...), or { in, out, direction, duration } for both ways */
  animation?: string | { in?: string; out?: string; direction?: string; duration?: number };
  /** a ring of light around it (shapes.css .aura): true, or the aura style name */
  aura?: boolean | string;
}
/** The toast region's options (configure() takes and returns them). */
interface ToastRegionOptions {
  /** 'list': every toast visible; 'pile': the newest in front, the others as sheets behind it */
  stack?: 'list' | 'pile';
  /** the corner the toasts appear in */
  position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'top-center' | 'bottom-center';
}
const toastStates = ['default'];
/** setState() configs per state (getState() reports how many toasts show). */
export interface ToastStateConfigs {
  /** The region as authored - setting it dismisses every visible toast. */
  default: {
    /** reported by getState(): the toasts in the region now */
    count?: number;
  };
}
/**
 * The markup of a state, for render(): the attributes a state writes, applied
 * to a detached copy of the authored markup ('default' IS the authored
 * markup). The live element gets the same markup from triggerStateChange -
 * the e2e render round trip proves they agree.
 */
function applyMarkup(_el, _stateName) {
  // one state, and its markup is the region as authored: the toasts inside
  // are created and dismissed at runtime (runtime-owned, see the e2e)
}
/**
 * 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;
  dfDollar(container).find('.toast').toArray().forEach((el) => toastDismiss(el));
}
/** Registry-level API; pass the container explicitly. Unknown names throw. */
export const toastApi = componentState({
  component: 'toast',
  states: toastStates,
  apply: (container, state) => {
    triggerStateChange(container, state.name, state.config);
    // SPA navigation may replace it)
  },
  read: (container, state) => {
    return {
      name: container.dataset.stateName || 'default',
      // live count - reflects df$.toast.show() and auto-dismiss, not just setState
      config: { ...state.config, count: dfDollar(container).find('.toast').toArray().length },
    };
  },
  markup: (el, state) => applyMarkup(el, state.name),
});
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 = dfDollar('#toast-container').get(0);
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 = [...dfDollar(container).find('.toast:not([data-leaving])').toArray()];
  // 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?: () => void) => {
  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(); });
};
/**
 * Show a toast - a title string or { title, description, variant, duration, action ... }.
 * @param options - the title, or the toast options
 * @returns the toast element (a manual popover in the region)
 */
const toastCreate = (options: string | ToastOptions): HTMLElement => {
  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 = dfDollar(toastContainer).find('.toast').toArray();
  // 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() {
  dfDollar('#toast-container:not([data-init])').toArray().forEach((container) => {
    container.dataset.init = '';
    // el.store + el.api (AGENTS.md "State through stores")
    bindComponent(container, toastApi);
    // 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 as HTMLElement).closest('.toast')) expand(true); });
    container.addEventListener('pointerout', (e) => { if (!(e.relatedTarget as HTMLElement | null)?.closest?.('.toast')) expand(false); });
    container.addEventListener('focusin', () => expand(true));
    container.addEventListener('focusout', (e) => { if (!(e.relatedTarget as HTMLElement | null)?.closest?.('.toast')) expand(false); });
    container.addEventListener('click', (e) => {
      const btn = (e.target as HTMLElement).closest<HTMLElement>('[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).
 * @param opts - the options to change; omitted keys stay as they are
 * @returns the region's options now
 */
const toastConfigure = (opts: ToastRegionOptions = {}): ToastRegionOptions => {
  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') as ToastRegionOptions['stack'], position: toastContainer.dataset.position as ToastRegionOptions['position'] };
};
/** df$.shadcn.toast - show and dismiss toasts in the page's region. */
export const toastActions = {
  configure: toastConfigure,
  show: toastCreate,
  /**
   * show() as a success toast.
   * @param options - the title, or the toast options (the variant is set for you)
   * @returns the toast element
   */
  success: (options: string | ToastOptions): HTMLElement => toastCreate(Object.assign(typeof options === 'string' ? { title: options } : options, { variant: 'success' as const })),
  /**
   * show() as a warning toast.
   * @param options - the title, or the toast options (the variant is set for you)
   * @returns the toast element
   */
  warning: (options: string | ToastOptions): HTMLElement => toastCreate(Object.assign(typeof options === 'string' ? { title: options } : options, { variant: 'warning' as const })),
  /**
   * show() as an info toast.
   * @param options - the title, or the toast options (the variant is set for you)
   * @returns the toast element
   */
  info: (options: string | ToastOptions): HTMLElement => toastCreate(Object.assign(typeof options === 'string' ? { title: options } : options, { variant: 'info' as const })),
  /**
   * show() as an error (destructive) toast.
   * @param options - the title, or the toast options (the variant is set for you)
   * @returns the toast element
   */
  error: (options: string | ToastOptions): HTMLElement => toastCreate(Object.assign(typeof options === 'string' ? { title: options } : options, { variant: 'destructive' as const })),
  /** Dismiss every toast. */
  dismiss: (): void => { dfDollar(toastContainer).find('.toast').toArray().forEach((el) => { toastDismiss(el); }); }
};
df$.toast = toastActions;

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