Cards
Toast Stack
Actionable notifications with async retry, persistent errors, accessible announcements, and timers that pause while you interact.
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/toast-stack.json'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
const css = `.ts-root {
--ts-ink: #28372e; --ts-muted: #657260; --ts-surface: #f8f9f3; --ts-line: #d9e1d1; --ts-accent: #486a32; --ts-soft: #eaf0df; --ts-error: #a63e21;
width: min(100%, 360px); color: var(--ts-ink); font: 13px/1.5 system-ui, sans-serif;
}
.ts-root * { box-sizing: border-box; }
.ts-root:focus-visible { outline: 2px solid var(--ts-accent); outline-offset: 5px; border-radius: 16px; }
.ts-list { display: grid; gap: 9px; list-style: none; padding: 0; margin: 0; }
.ts-item { position: relative; display: flex; align-items: flex-start; gap: 11px; padding: 16px 12px 16px 16px; border: 1px solid var(--ts-line); border-radius: 16px; background: var(--ts-surface); box-shadow: 0 8px 25px #1724170c; animation: ts-enter 240ms cubic-bezier(.2,.8,.2,1); }
.ts-icon { flex: none; display: grid; place-items: center; width: 29px; height: 29px; border: 1px solid var(--ts-line); border-radius: 9px; background: var(--ts-soft); color: var(--ts-accent); font: 600 15px/1 system-ui, sans-serif; }
.ts-item[data-tone="error"] .ts-icon { color: var(--ts-error); background: #d7745312; }
.ts-content { min-width: 0; flex: 1; padding-top: 1px; }
.ts-title { margin: 0; font-size: 13px; line-height: 1.45; font-weight: 650; letter-spacing: -.015em; overflow-wrap: anywhere; }
.ts-message { margin: 4px 0 0; font-size: 11px; line-height: 1.5; color: var(--ts-muted); overflow-wrap: anywhere; }
.ts-root button { font: inherit; cursor: pointer; }
.ts-root button:focus-visible { outline: 2px solid var(--ts-accent); outline-offset: 3px; }
.ts-dismiss { display: grid; place-items: center; flex: none; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 7px; color: var(--ts-muted); background: transparent; font-size: 19px !important; }
.ts-dismiss:hover, .ts-action:hover { background: var(--ts-soft); }
.ts-action { min-height: 30px; padding: 5px 10px; margin-top: 10px; border: 1px solid var(--ts-line); border-radius: 7px; color: var(--ts-accent); background: transparent; font-size: 11px !important; font-weight: 600 !important; }
.ts-root button:disabled, .ts-root button[aria-disabled="true"] { opacity: .6; cursor: wait; }
.ts-error { margin: 7px 0 0; color: var(--ts-error); font-size: 11px; }
.ts-error:empty { display: none; }
.ts-meta { display: block; color: var(--ts-muted); font: 9px/1.5 ui-monospace, monospace; margin-top: 9px; letter-spacing: .03em; }
.ts-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
:root[data-theme="dark"] .ts-root { --ts-ink: #e7edde; --ts-muted: #a4b09b; --ts-surface: #242d22; --ts-line: #414e3a; --ts-accent: #b9d891; --ts-soft: #2c3826; --ts-error: #ffab96; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .ts-root { --ts-ink: #e7edde; --ts-muted: #a4b09b; --ts-surface: #242d22; --ts-line: #414e3a; --ts-accent: #b9d891; --ts-soft: #2c3826; --ts-error: #ffab96; }
}
@keyframes ts-enter { from { opacity: 0; transform: translateY(7px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
@media (prefers-reduced-motion: reduce) { .ts-item { animation: none; } }
`;
type Toast = {
/** Unique for the lifetime of the notification. Use a fresh ID for a new one. */
id: string;
title: string;
message?: string;
tone?: 'info' | 'success' | 'error';
/** Milliseconds; 0 keeps it visible. Actionable/error toasts persist by default. */
duration?: number;
action?: { label: string; onClick: () => void | Promise<void> };
};
function durationOf(toast: Toast) {
const value = toast.duration ?? (toast.action || toast.tone === 'error' ? 0 : 6000);
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
export function ToastStack({ toasts, onDismiss, label = 'Notifications' }: {
toasts: Toast[];
/** Remove the requested ID from your toasts array. */
onDismiss: (id: string) => void;
label?: string;
}) {
const root = useRef<HTMLElement>(null);
const hovered = useRef(false);
const timers = useRef(new Map<string, { remaining: number; duration: number; expired: boolean }>());
const latest = useRef<{ busy: string[]; dismiss: (id: string) => void }>({ busy: [], dismiss: () => {} });
const announced = useRef(new Set<string>());
const [announcement, setAnnouncement] = useState('');
const [busy, setBusy] = useState<string[]>([]);
const [errors, setErrors] = useState<Record<string, string>>({});
const dismiss = useCallback((id: string) => {
const cards = Array.from(root.current?.querySelectorAll<HTMLElement>('.ts-item') || []);
const current = cards.find(card => card.dataset.toastId === id);
if (current?.contains(document.activeElement)) {
const other = cards.find(card => card !== current);
(other?.querySelector<HTMLButtonElement>('button:not(:disabled):not([aria-disabled="true"])') || root.current)?.focus();
}
onDismiss(id);
}, [onDismiss]);
useEffect(() => {
latest.current = { busy, dismiss };
const active = new Set(toasts.map(toast => toast.id));
for (const id of timers.current.keys()) if (!active.has(id)) timers.current.delete(id);
for (const toast of toasts) {
const duration = durationOf(toast);
if (timers.current.get(toast.id)?.duration !== duration) timers.current.set(toast.id, { remaining: duration, duration, expired: false });
}
const fresh = toasts.filter(toast => !announced.current.has(toast.id));
if (fresh.length) setAnnouncement(fresh.map(toast => `${toast.title}. ${toast.message || ''}`).join(' '));
announced.current = active;
}, [toasts, busy, dismiss]);
// Keep one clock across host re-renders. Restarting an interval whenever the
// parent's array or callback changes can prevent expiry in a busy dashboard.
useEffect(() => {
let last = Date.now();
const visibility = () => { last = Date.now(); };
document.addEventListener('visibilitychange', visibility);
const timer = window.setInterval(() => {
const now = Date.now();
const elapsed = now - last;
last = now;
if (hovered.current || root.current?.contains(document.activeElement) || document.hidden) return;
for (const [id, clock] of timers.current) {
if (!clock.duration || clock.expired || latest.current.busy.includes(id)) continue;
clock.remaining -= elapsed;
if (clock.remaining <= 0) { clock.expired = true; latest.current.dismiss(id); }
}
}, 100);
return () => { window.clearInterval(timer); document.removeEventListener('visibilitychange', visibility); };
}, []);
async function runAction(toast: Toast) {
if (!toast.action || busy.includes(toast.id)) return;
setBusy(current => [...current, toast.id]);
setErrors(current => ({ ...current, [toast.id]: '' }));
try {
await toast.action.onClick();
dismiss(toast.id);
} catch {
setErrors(current => ({ ...current, [toast.id]: 'That action did not complete. Try again.' }));
} finally {
setBusy(current => current.filter(id => id !== toast.id));
}
}
return <>
<style>{css}</style>
<section ref={root} className="ts-root" aria-label={label} tabIndex={-1}
onPointerEnter={() => { hovered.current = true; }} onPointerLeave={() => { hovered.current = false; }}>
<p className="ts-sr" role="status" aria-live="polite" aria-atomic="true">{announcement}</p>
<ol className="ts-list">{toasts.map(toast => <li className="ts-item" key={toast.id} data-toast-id={toast.id} data-tone={toast.tone || 'info'} aria-busy={busy.includes(toast.id)}>
<span className="ts-icon" aria-hidden="true">{toast.tone === 'success' ? '✓' : toast.tone === 'error' ? '!' : 'i'}</span>
<div className="ts-content">
<div><p className="ts-title">{toast.title}</p>{toast.message && <p className="ts-message">{toast.message}</p>}</div>
{toast.action && <button type="button" className="ts-action" aria-disabled={busy.includes(toast.id)} onClick={() => { void runAction(toast); }}>{busy.includes(toast.id) ? 'Working…' : toast.action.label}</button>}
<p className="ts-error" role="alert">{errors[toast.id]}</p>
<span className="ts-meta">{durationOf(toast) ? 'Auto dismiss · pauses while you interact' : 'Stays until dismissed'}</span>
</div>
<button type="button" className="ts-dismiss" aria-label={`Dismiss ${toast.title}`} aria-disabled={busy.includes(toast.id)} onClick={() => { if (!busy.includes(toast.id)) dismiss(toast.id); }}>×</button>
</li>)}</ol>
</section>
</>;
}