ToastMOL
Transient notification using popover="manual". Imperative \df$.shadcn.toast.show() API with auto-dismiss, stacking, destructive variant, and action buttons.
On this page (11)
§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 aswindow.\df$.shadcn.toast.dismiss());getState().config.countreports 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:
| State | Type | Values | Default | Description |
|---|
§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