Toast Notification Stack
A Sonner-style toast system with no dependencies: toasts fold into a deck, fan out on hover, focus or tap, and pause their progress lines while open. Drag to throw one away, success, error and info types, an action button, and a loading toast that becomes a success or error in place.
DOM + CSS0 deps1 file · 28 KB TSXreduced-motion aware
Props
"use client";
/*
* Toast stack: a Sonner-style notification system in one file, no dependencies.
*
* useToasts() returns pushToast, dismiss, update, promise and a <ToastStack />
* to render once. Toasts pile up as a deck (older ones peek out behind the
* newest), fan out into a list on hover, focus or tap, and every one carries a
* progress line that pauses while the stack is open. Drag a toast sideways, or
* toward the screen edge, to throw it away. A loading toast turns into a
* success or an error in place.
*/
import {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
/* ---- Types -------------------------------------------------------------- */
export type ToastType = "default" | "success" | "error" | "info" | "warning" | "loading";
export type ToastAction = { label: string; onClick?: () => void };
export type ToastOptions = {
type?: ToastType;
/** A second, quieter line under the message. */
description?: string;
/** A button on the toast. Clicking it runs onClick and dismisses the toast. */
action?: ToastAction;
/** ms before this toast dismisses itself. 0 keeps it until dismissed. Overrides the hook's duration. */
duration?: number;
/** Reuse an id to update a toast that is still showing instead of adding another. */
id?: number;
};
type ToastPosition = "bottom" | "top";
type ToastAlign = "center" | "left" | "right";
type Config = {
duration: number;
position: ToastPosition;
align: ToastAlign;
container: "viewport" | "parent";
visibleToasts: number;
limit: number;
width: number;
};
type ToastRecord = {
id: number;
seq: number;
message: string;
description?: string;
type: ToastType;
action?: ToastAction;
/** Lifetime in ms. Infinity means it stays until dismissed. */
total: number;
/** Time left. Counted down by the viewport's frame loop, never rendered directly. */
remaining: number;
removed: boolean;
exit?: { x: number; y: number };
};
type Snapshot = { toasts: ToastRecord[]; config: Config };
type ResultText = string | { message: string; description?: string };
/* ---- Constants ---------------------------------------------------------- */
const EXIT_MS = 340;
const GAP = 10;
const PEEK = 14;
const FALLBACK_H = 64;
const EASE_OUT = "cubic-bezier(0.22, 1, 0.36, 1)";
const EASE_SPRING = "cubic-bezier(0.22, 1.12, 0.36, 1)";
const TONE: Record<ToastType, { fg: string; rgb: string }> = {
default: { fg: "#e4e4e7", rgb: "228,228,231" },
success: { fg: "#4ade80", rgb: "74,222,128" },
error: { fg: "#f87171", rgb: "248,113,113" },
info: { fg: "#8ab0ff", rgb: "138,176,255" },
warning: { fg: "#fbbf24", rgb: "251,191,36" },
loading: { fg: "#c4c4cc", rgb: "196,196,204" },
};
let toastId = 0;
let toastSeq = 0;
/* ---- Store -------------------------------------------------------------- */
function lifetime(type: ToastType, duration: number | undefined, fallback: number): number {
if (duration !== undefined) return duration > 0 ? duration : Infinity;
return type === "loading" ? Infinity : fallback;
}
function text(r: ResultText): { message: string; description?: string } {
return typeof r === "string" ? { message: r } : r;
}
function createToastStore(initial: Config) {
let snap: Snapshot = { toasts: [], config: initial };
const serverSnap: Snapshot = snap;
const subs = new Set<() => void>();
function commit(next: Partial<Snapshot>) {
snap = { ...snap, ...next };
subs.forEach((f) => f());
}
function dismiss(id?: number, exit?: { x: number; y: number }) {
const gone: number[] = [];
const toasts = snap.toasts.map((t) => {
if (t.removed || (id !== undefined && t.id !== id)) return t;
gone.push(t.id);
return { ...t, removed: true, exit };
});
if (gone.length === 0) return;
commit({ toasts });
window.setTimeout(() => {
commit({ toasts: snap.toasts.filter((t) => !gone.includes(t.id)) });
}, EXIT_MS);
}
function update(id: number, patch: ToastOptions & { message?: string }) {
const prev = snap.toasts.find((t) => t.id === id && !t.removed);
if (!prev) return;
const type = patch.type ?? prev.type;
const total = lifetime(type, patch.duration, snap.config.duration);
const next: ToastRecord = {
...prev,
message: patch.message ?? prev.message,
description: "description" in patch ? patch.description : prev.description,
action: "action" in patch ? patch.action : prev.action,
type,
total,
remaining: total,
};
commit({ toasts: snap.toasts.map((t) => (t === prev ? next : t)) });
}
function push(message: string, options: ToastOptions = {}): number {
const id = options.id ?? toastId++;
if (snap.toasts.some((t) => t.id === id && !t.removed)) {
update(id, { ...options, message });
return id;
}
const type = options.type ?? "default";
const total = lifetime(type, options.duration, snap.config.duration);
const rec: ToastRecord = {
id,
seq: toastSeq++,
message,
description: options.description,
type,
action: options.action,
total,
remaining: total,
removed: false,
};
commit({ toasts: [...snap.toasts, rec] });
const live = snap.toasts.filter((t) => !t.removed);
for (let k = 0; k < live.length - snap.config.limit; k++) {
const old = live[k];
if (old) dismiss(old.id);
}
return id;
}
function promise<T>(
work: Promise<T> | (() => Promise<T>),
msgs: {
loading: ResultText;
success: ResultText | ((value: T) => ResultText);
error: ResultText | ((error: unknown) => ResultText);
},
options: Omit<ToastOptions, "type" | "id"> = {},
): number {
const first = text(msgs.loading);
const id = push(first.message, { ...options, type: "loading", description: first.description });
const run = typeof work === "function" ? work() : work;
run.then(
(value) => {
const r = text(typeof msgs.success === "function" ? msgs.success(value) : msgs.success);
update(id, { type: "success", message: r.message, description: r.description });
},
(err: unknown) => {
const r = text(typeof msgs.error === "function" ? msgs.error(err) : msgs.error);
update(id, { type: "error", message: r.message, description: r.description });
},
);
return id;
}
/** Count every running timer down by dt ms; toasts that reach zero dismiss themselves. */
function tick(dt: number) {
const expired: number[] = [];
for (const t of snap.toasts) {
if (t.removed || !Number.isFinite(t.total)) continue;
t.remaining -= dt;
if (t.remaining <= 0) expired.push(t.id);
}
expired.forEach((id) => dismiss(id));
}
return {
subscribe(cb: () => void) {
subs.add(cb);
return () => {
subs.delete(cb);
};
},
getSnapshot: () => snap,
getServerSnapshot: () => serverSnap,
configure(config: Config) {
const c = snap.config;
if (
c.duration === config.duration &&
c.position === config.position &&
c.align === config.align &&
c.container === config.container &&
c.visibleToasts === config.visibleToasts &&
c.limit === config.limit &&
c.width === config.width
)
return;
commit({ config });
},
push,
update,
dismiss,
promise,
tick,
};
}
type Store = ReturnType<typeof createToastStore>;
/* ---- Hook --------------------------------------------------------------- */
export function useToasts({
duration = 2200,
position = "bottom",
align = "center",
container = "viewport",
visibleToasts = 3,
limit = 8,
width = 360,
}: {
/** ms before a toast auto-dismisses. */
duration?: number;
/** Which edge the stack grows from. */
position?: "bottom" | "top";
/** Where the stack sits along that edge. */
align?: "center" | "left" | "right";
/** "viewport" pins the stack to the window; "parent" pins it inside the nearest positioned ancestor. */
container?: "viewport" | "parent";
/** How many toasts show in the folded deck. */
visibleToasts?: number;
/** The most toasts kept at once; the oldest is dismissed past this. */
limit?: number;
/** Toast width in px (never wider than the screen). */
width?: number;
} = {}) {
const [store] = useState(() =>
createToastStore({ duration, position, align, container, visibleToasts, limit, width }),
);
useEffect(() => {
store.configure({ duration, position, align, container, visibleToasts, limit, width });
}, [store, duration, position, align, container, visibleToasts, limit, width]);
const [ToastStack] = useState(() => {
const Stack = () => <Viewport store={store} />;
return Stack;
});
return {
pushToast: store.push,
dismiss: store.dismiss,
update: store.update,
promise: store.promise,
ToastStack,
};
}
/* ---- Styles ------------------------------------------------------------- */
const CSS = [
".ts-root{position:absolute;height:0;z-index:60;pointer-events:none;outline:none;font-family:inherit}",
".ts-root[data-fixed='true']{position:fixed}",
".ts-item{position:absolute;left:0;right:0;margin-inline:auto;list-style:none;pointer-events:auto;touch-action:none;user-select:none;-webkit-user-select:none;will-change:transform,opacity}",
".ts-root[data-align='left'] .ts-item{margin-left:0;margin-right:auto}",
".ts-root[data-align='right'] .ts-item{margin-left:auto;margin-right:0}",
".ts-item[data-dead='true']{pointer-events:none}",
".ts-item::after{content:'';position:absolute;left:0;right:0;height:" + (GAP + 20) + "px}",
".ts-root[data-pos='bottom'] .ts-item::after{bottom:100%}",
".ts-root[data-pos='top'] .ts-item::after{top:100%}",
".ts-card{position:relative;height:100%;overflow:hidden;border-radius:16px;border:1px solid rgba(255,255,255,.1);color:#f5f5f6;background:linear-gradient(180deg,rgba(32,33,38,.94),rgba(17,18,21,.95));-webkit-backdrop-filter:blur(18px) saturate(1.5);backdrop-filter:blur(18px) saturate(1.5);box-shadow:inset 0 1px 0 rgba(255,255,255,.07),0 1px 2px rgba(0,0,0,.4),0 14px 34px -10px rgba(0,0,0,.65),0 30px 60px -24px rgba(0,0,0,.5)}",
".ts-tint{position:absolute;inset:0;pointer-events:none;border-radius:inherit}",
".ts-glow{position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity .3s ease;background:radial-gradient(200px circle at var(--mx,50%) var(--my,0%),rgba(255,255,255,.075),transparent 70%)}",
".ts-item:hover .ts-glow{opacity:1}",
".ts-inner{position:relative;display:flex;align-items:center;gap:12px;padding:13px 12px 13px 14px;transition:opacity .3s ease}",
".ts-inner[data-hide='true']{opacity:0}",
".ts-icon{flex:none;display:grid;place-items:center;width:24px;height:24px;border-radius:999px;align-self:flex-start;margin-top:-1px}",
".ts-icon svg{width:15px;height:15px;overflow:visible}",
".ts-draw{stroke-dasharray:1;stroke-dashoffset:1;animation:ts-draw .5s .12s cubic-bezier(.3,.7,.3,1) forwards}",
".ts-pop{transform-origin:center;animation:ts-pop .5s cubic-bezier(.3,1.6,.5,1) both}",
".ts-spin{transform-origin:10px 10px;animation:ts-spin .8s linear infinite}",
".ts-body{min-width:0;flex:1}",
".ts-msg{font-size:13.5px;line-height:18px;font-weight:560;letter-spacing:-.006em;color:#f5f5f6;animation:ts-text .32s " + EASE_OUT + " both;overflow-wrap:anywhere}",
".ts-desc{margin-top:2px;font-size:12.5px;line-height:17px;font-weight:400;color:#a0a2ab;animation:ts-text .32s .05s " + EASE_OUT + " both;overflow-wrap:anywhere}",
".ts-btn{flex:none;appearance:none;cursor:pointer;display:grid;place-items:center;border:0;font:inherit;color:#f5f5f6;transition:background-color .18s ease,color .18s ease,opacity .18s ease,transform .18s ease}",
".ts-btn:active{transform:scale(.94)}",
".ts-act{height:28px;padding:0 12px;border-radius:999px;font-size:12px;font-weight:600;background:rgba(255,255,255,.11);box-shadow:inset 0 0 0 1px rgba(255,255,255,.08)}",
".ts-act:hover{background:rgba(255,255,255,.2)}",
".ts-close{width:26px;height:26px;border-radius:8px;background:transparent;color:#8d8f98;opacity:0}",
".ts-close:hover{background:rgba(255,255,255,.1);color:#f5f5f6}",
".ts-item:hover .ts-close,.ts-item:focus-within .ts-close{opacity:1}",
".ts-btn:focus-visible{outline:2px solid #fff!important;outline-offset:2px!important;opacity:1}",
".ts-act:focus-visible{border-radius:999px!important}",
".ts-close:focus-visible{border-radius:8px!important}",
".ts-bar{position:absolute;left:0;right:0;bottom:0;height:2px;transform-origin:0 50%;opacity:.5;transition:opacity .25s ease}",
".ts-root[data-paused='true'] .ts-bar{opacity:1}",
"@media (pointer:coarse){.ts-close{opacity:1}.ts-act{height:40px;padding:0 16px}.ts-close{width:40px;height:40px}.ts-inner{padding-block:10px}}",
"@keyframes ts-draw{to{stroke-dashoffset:0}}",
"@keyframes ts-pop{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}",
"@keyframes ts-spin{to{transform:rotate(360deg)}}",
"@keyframes ts-text{from{opacity:0;transform:translateY(4px);filter:blur(3px)}to{opacity:1;transform:none;filter:none}}",
"@media (prefers-reduced-motion:reduce){.ts-item{transition-property:opacity!important;transition-duration:.2s!important}.ts-msg,.ts-desc,.ts-pop{animation:none}.ts-draw{animation:none;stroke-dashoffset:0}.ts-spin{animation-duration:2.4s}}",
].join("\n");
/* ---- Pieces ------------------------------------------------------------- */
function Glyph({ type }: { type: ToastType }) {
const tone = TONE[type];
return (
<span
className="ts-icon ts-pop"
key={type}
style={{ color: tone.fg, background: "rgba(" + tone.rgb + ",.14)", boxShadow: "inset 0 0 0 1px rgba(" + tone.rgb + ",.3)" }}
aria-hidden="true"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{type === "success" && <path className="ts-draw" pathLength={1} d="M5.5 10.4l3 3 6-6.4" />}
{type === "error" && <path className="ts-draw" pathLength={1} d="M6.5 6.5l7 7M13.5 6.5l-7 7" />}
{type === "info" && (
<>
<path d="M10 9v5" />
<circle cx="10" cy="5.8" r="0.6" fill="currentColor" />
</>
)}
{type === "warning" && (
<>
<path d="M10 5.5v5.4" />
<circle cx="10" cy="14.4" r="0.6" fill="currentColor" />
</>
)}
{type === "loading" && (
<g className="ts-spin">
<circle cx="10" cy="10" r="6.5" strokeOpacity="0.25" />
<path d="M10 3.5a6.5 6.5 0 0 1 6.5 6.5" />
</g>
)}
{type === "default" && <circle cx="10" cy="10" r="2.4" fill="currentColor" stroke="none" />}
</svg>
</span>
);
}
type ItemProps = {
t: ToastRecord;
store: Store;
dir: 1 | -1;
offset: number;
scale: number;
height: number | undefined;
foldedHeight: number | null;
folded: boolean;
beyond: boolean;
width: number;
onHeight: (id: number, h: number) => void;
registerBar: (id: number, el: HTMLElement | null) => void;
onDragActive: (id: number | null) => void;
onTap: () => void;
refocus: () => void;
};
function ToastItem({
t,
store,
dir,
offset,
scale,
height,
foldedHeight,
folded,
beyond,
width,
onHeight,
registerBar,
onDragActive,
onTap,
refocus,
}: ItemProps) {
const liRef = useRef<HTMLLIElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
const [drag, setDrag] = useState({ x: 0, y: 0, active: false });
const gesture = useRef<{
x0: number;
y0: number;
k: number;
lastX: number;
lastT: number;
vx: number;
moved: boolean;
touch: boolean;
} | null>(null);
const outward = -dir;
useEffect(() => {
let b = 0;
const a = requestAnimationFrame(() => {
b = requestAnimationFrame(() => setMounted(true));
});
return () => {
cancelAnimationFrame(a);
cancelAnimationFrame(b);
};
}, []);
useEffect(() => {
const el = innerRef.current;
if (!el) return;
const ro = new ResizeObserver(() => onHeight(t.id, el.offsetHeight + 2));
ro.observe(el);
return () => ro.disconnect();
}, [t.id, onHeight]);
useEffect(() => {
liRef.current?.toggleAttribute("inert", folded || t.removed);
}, [folded, t.removed]);
function leave(exit: { x: number; y: number }) {
store.dismiss(t.id, exit);
refocus();
}
function onPointerDown(e: ReactPointerEvent<HTMLLIElement>) {
if (e.button !== 0 || t.removed) return;
if ((e.target as HTMLElement).closest("button")) return;
const li = e.currentTarget;
const rect = li.getBoundingClientRect();
gesture.current = {
x0: e.clientX,
y0: e.clientY,
k: rect.width > 0 ? li.offsetWidth / rect.width : 1,
lastX: e.clientX,
lastT: e.timeStamp,
vx: 0,
moved: false,
touch: e.pointerType === "touch",
};
li.setPointerCapture(e.pointerId);
}
function onPointerMove(e: ReactPointerEvent<HTMLLIElement>) {
const g = gesture.current;
const li = e.currentTarget;
if (!g) {
if (e.pointerType === "mouse") {
const r = li.getBoundingClientRect();
const k = r.width > 0 ? li.offsetWidth / r.width : 1;
li.style.setProperty("--mx", (e.clientX - r.left) * k + "px");
li.style.setProperty("--my", (e.clientY - r.top) * k + "px");
}
return;
}
const dx = (e.clientX - g.x0) * g.k;
const dy = (e.clientY - g.y0) * g.k;
if (!g.moved && Math.hypot(dx, dy) < 5) return;
if (!g.moved) {
g.moved = true;
onDragActive(t.id);
}
const dt = Math.max(1, e.timeStamp - g.lastT);
g.vx = g.vx * 0.6 + (((e.clientX - g.lastX) * g.k) / dt) * 0.4;
g.lastX = e.clientX;
g.lastT = e.timeStamp;
const out = dy * outward;
setDrag({ x: dx, y: out > 0 ? dy : dy * 0.12, active: true });
}
function end(e: ReactPointerEvent<HTMLLIElement>, cancelled: boolean) {
const g = gesture.current;
gesture.current = null;
if (!g) return;
if (!g.moved) {
if (!cancelled && g.touch) onTap();
return;
}
onDragActive(null);
const dx = (e.clientX - g.x0) * g.k;
const out = (e.clientY - g.y0) * g.k * outward;
const fast = Math.abs(g.vx) > 0.55 && Math.abs(dx) > 30;
if (!cancelled && (Math.abs(dx) > 96 || out > 56 || fast)) {
if (Math.abs(dx) >= out) leave({ x: Math.sign(dx || 1) * (width + 60), y: drag.y });
else leave({ x: dx, y: outward * 90 });
setDrag({ x: 0, y: 0, active: false });
return;
}
setDrag({ x: 0, y: 0, active: false });
}
function onKeyDown(e: ReactKeyboardEvent<HTMLLIElement>) {
if (e.key === "Escape") {
e.stopPropagation();
leave({ x: 0, y: outward * 24 });
}
}
const y = dir * offset;
const hh = height ?? FALLBACK_H;
let transform: string;
let opacity = 1;
let transition: string;
if (t.removed) {
const ex = t.exit ?? { x: 0, y: outward * 24 };
transform = "translate3d(" + ex.x + "px," + (y + ex.y) + "px,0) scale(0.95)";
opacity = 0;
transition = "transform " + EXIT_MS + "ms cubic-bezier(0.4,0,0.9,0.5), opacity 260ms ease, height 0s";
} else if (!mounted) {
transform = "translate3d(0," + (y + outward * (hh * 0.7 + 10)) + "px,0) scale(0.94)";
opacity = 0;
transition = "none";
} else if (drag.active) {
transform = "translate3d(" + drag.x + "px," + (y + drag.y) + "px,0) scale(" + scale + ")";
opacity = 1 - Math.min(0.55, Math.abs(drag.x) / 340);
transition = "height 0.45s " + EASE_OUT;
} else {
transform = "translate3d(0," + y + "px,0) scale(" + scale + ")";
opacity = beyond ? 0 : 1;
transition =
"transform 0.55s " + EASE_SPRING + ", opacity 0.35s ease, height 0.45s " + EASE_OUT;
}
const tone = TONE[t.type];
const finite = Number.isFinite(t.total);
const shownH = folded && foldedHeight !== null ? foldedHeight : height;
return (
<li
ref={liRef}
className="ts-item"
data-dead={t.removed || beyond ? "true" : "false"}
role={t.type === "error" ? "alert" : undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={(e) => end(e, false)}
onPointerCancel={(e) => end(e, true)}
onKeyDown={onKeyDown}
style={{
[dir === -1 ? "bottom" : "top"]: 0,
width: "min(" + width + "px, 100%)",
height: shownH,
transform,
opacity,
transition,
transformOrigin: dir === -1 ? "50% 100%" : "50% 0%",
zIndex: 1000 - Math.round(offset),
cursor: drag.active ? "grabbing" : undefined,
}}
>
<div className="ts-card">
<div
className="ts-tint"
style={{ background: "radial-gradient(240px 110px at 0% 0%, rgba(" + tone.rgb + ",.13), transparent 72%)" }}
/>
<div className="ts-glow" />
<div ref={innerRef} className="ts-inner" data-hide={folded ? "true" : "false"}>
<Glyph type={t.type} />
<div className="ts-body">
<div className="ts-msg" key={t.type + t.message}>
{t.message}
</div>
{t.description ? (
<div className="ts-desc" key={t.type + t.description}>
{t.description}
</div>
) : null}
</div>
{t.action ? (
<button
type="button"
className="ts-btn ts-act"
onClick={() => {
t.action?.onClick?.();
leave({ x: 0, y: outward * 24 });
}}
>
{t.action.label}
</button>
) : null}
<button
type="button"
className="ts-btn ts-close"
aria-label={"Dismiss notification: " + t.message}
onClick={() => leave({ x: 0, y: outward * 24 })}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" aria-hidden="true">
<path d="M2.5 2.5l7 7M9.5 2.5l-7 7" />
</svg>
</button>
</div>
{finite ? (
<div
ref={(el) => registerBar(t.id, el)}
className="ts-bar"
style={{ background: "linear-gradient(90deg, rgba(" + tone.rgb + ",.9), rgba(" + tone.rgb + ",.35))" }}
/>
) : null}
</div>
</li>
);
}
/* ---- Viewport ----------------------------------------------------------- */
function Viewport({ store }: { store: Store }) {
const snap = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
const { toasts, config } = snap;
const rootRef = useRef<HTMLOListElement>(null);
const bars = useRef(new Map<number, HTMLElement>());
const pausedRef = useRef(false);
const [hovered, setHovered] = useState(false);
const [touchOpen, setTouchOpen] = useState(false);
const [focused, setFocused] = useState(false);
const [dragId, setDragId] = useState<number | null>(null);
const [heights, setHeights] = useState<Record<number, number>>({});
const live = toasts.filter((t) => !t.removed);
const liveCount = live.length;
if (liveCount === 0 && (hovered || touchOpen || focused)) {
setHovered(false);
setTouchOpen(false);
setFocused(false);
}
const expanded = liveCount > 0 && (hovered || touchOpen || focused);
const paused = expanded || dragId !== null;
const timed = live.some((t) => Number.isFinite(t.total));
useEffect(() => {
pausedRef.current = paused;
}, [paused]);
useEffect(() => {
if (!timed) return;
let raf = 0;
let last = -1;
const frame = (now: number) => {
if (last >= 0) {
const dt = Math.min(64, now - last);
if (!pausedRef.current) store.tick(dt);
}
last = now;
for (const t of store.getSnapshot().toasts) {
const el = bars.current.get(t.id);
if (el && Number.isFinite(t.total)) {
el.style.transform = "scaleX(" + Math.max(0, Math.min(1, t.remaining / t.total)) + ")";
}
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [timed, store]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.altKey && e.code === "KeyT") {
e.preventDefault();
rootRef.current?.focus();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
useEffect(() => {
if (!touchOpen) return;
const onDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setTouchOpen(false);
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [touchOpen]);
const onHeight = useCallback((id: number, h: number) => {
setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));
}, []);
const registerBar = useCallback((id: number, el: HTMLElement | null) => {
if (el) bars.current.set(id, el);
else bars.current.delete(id);
}, []);
const refocus = useCallback(() => {
const root = rootRef.current;
if (root && root.contains(document.activeElement)) root.focus();
}, []);
const onTap = useCallback(() => setTouchOpen((v) => !v), []);
const dir: 1 | -1 = config.position === "bottom" ? -1 : 1;
const hOf = (id: number) => heights[id] ?? FALLBACK_H;
const front = live[live.length - 1];
const frontH = front ? hOf(front.id) : FALLBACK_H;
const ordered = [...toasts].sort((a, b) => b.seq - a.seq);
const viewport = config.container === "viewport";
const edge = "16px";
const vEdge = viewport ? "calc(" + edge + " + env(safe-area-inset-" + (dir === -1 ? "bottom" : "top") + ", 0px))" : "max(" + edge + ", 9%)";
return (
<>
<style>{CSS}</style>
<ol
ref={rootRef}
className="ts-root"
aria-label="Notifications (Alt+T)"
aria-live="polite"
aria-relevant="additions text"
tabIndex={-1}
data-fixed={viewport ? "true" : "false"}
data-pos={config.position}
data-align={config.align}
data-paused={paused ? "true" : "false"}
onPointerEnter={(e) => {
if (e.pointerType !== "touch") setHovered(true);
}}
onPointerLeave={(e) => {
if (e.pointerType !== "touch") setHovered(false);
}}
onFocus={(e: ReactFocusEvent<HTMLOListElement>) => {
setFocused(e.target.matches(":focus-visible"));
}}
onBlur={(e: ReactFocusEvent<HTMLOListElement>) => {
if (!e.currentTarget.contains(e.relatedTarget)) setFocused(false);
}}
style={{
left: edge,
right: edge,
[dir === -1 ? "bottom" : "top"]:
vEdge,
}}
>
{ordered.map((t) => {
const newer = live.filter((x) => x.seq > t.seq);
const i = newer.length;
const expandedOffset = newer.reduce((s, x) => s + hOf(x.id) + GAP, 0);
const offset = expanded ? expandedOffset : i * PEEK;
const scale = expanded ? 1 : Math.max(0.8, 1 - i * 0.05);
const folded = !expanded && i > 0 && !t.removed;
return (
<ToastItem
key={t.id}
t={t}
store={store}
dir={dir}
offset={offset}
scale={scale}
height={heights[t.id]}
foldedHeight={folded ? frontH : null}
folded={folded}
beyond={!expanded && i >= config.visibleToasts && !t.removed}
width={config.width}
onHeight={onHeight}
registerBar={registerBar}
onDragActive={setDragId}
onTap={onTap}
refocus={refocus}
/>
);
})}
</ol>
</>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this pattern
A toast system in one file with nothing to install. useToasts({ duration, position }) still returns pushToast and a ToastStack to render once, and pushToast(message) still works with a bare string. Added on top: pushToast(message, { type, description, action, duration, id }) with success, error, info, warning and loading types, plus promise(), update() and dismiss(). Toasts stack as a deck: the newest sits in front and up to three older ones peek out behind it, scaled down 5% per level. Hover, keyboard focus or a tap on a phone fans the deck into a list, and every progress line pauses while it is open (timers are counted down by a single frame loop, so a toast keeps exactly the time it had left). Drag a toast sideways or toward the screen edge, release past 96px or with a flick, and it is thrown off; otherwise it springs back. A loading toast, or promise(), turns into a success or error in place, with the icon redrawn and the text blurring in. Heights are measured, so toasts of any length reflow smoothly. More optional options on the hook: align, container (viewport or parent), visibleToasts, limit and width. Motion is CSS transitions on a spring-like cubic-bezier. The toast surface is its own dark glass and does not read your theme.
Curator’s note
Rebuilt in October without Framer Motion: the useToasts hook keeps its shape, and every toast now folds into a deck, fans out on hover and carries its own progress line.
