Toast stack
New toasts stack with depth, fan out on hover, count down with a progress line and swipe away.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./demo.module.css";
const MESSAGES = [
{ title: "Offer sent to Maya", body: "She will get it on WhatsApp." },
{ title: "Calendar synced", body: "Villa Azul is up to date." },
{ title: "Payment received", body: "€180 for Wed 14 is confirmed." },
{ title: "Late checkout added", body: "Casa Mar, Fri 20 until 2pm." },
];
const LIFE = 5000;
const MAX = 4;
type Toast = { id: number; title: string; body: string; leaving: boolean };
export default function Demo() {
const [toasts, setToasts] = useState<Toast[]>(() =>
MESSAGES.slice(0, 2).map((m, i) => ({ id: 2 - i, ...m, leaving: false })),
);
const count = useRef(2);
const timers = useRef(new Set<number>());
const drag = useRef<{ id: number; startX: number; dx: number } | null>(null);
useEffect(() => {
const set = timers.current;
return () => set.forEach(clearTimeout);
}, []);
const add = () => {
const m = MESSAGES[count.current % MESSAGES.length];
const id = ++count.current;
setToasts((list) => [{ id, ...m, leaving: false }, ...list].slice(0, MAX));
};
const dismiss = (id: number) => {
setToasts((list) => list.map((t) => (t.id === id ? { ...t, leaving: true } : t)));
const timer = window.setTimeout(() => {
timers.current.delete(timer);
setToasts((list) => list.filter((t) => t.id !== id));
}, 240);
timers.current.add(timer);
};
const down = (id: number) => (event: React.PointerEvent<HTMLLIElement>) => {
if (event.button !== 0 || (event.target as Element).closest("button")) return;
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.classList.add(styles.dragging);
drag.current = { id, startX: event.clientX, dx: 0 };
};
const move = (event: React.PointerEvent<HTMLLIElement>) => {
if (!drag.current) return;
drag.current.dx = event.clientX - drag.current.startX;
event.currentTarget.style.setProperty("--dx", `${drag.current.dx}px`);
};
const up = (event: React.PointerEvent<HTMLLIElement>) => {
const current = drag.current;
if (!current) return;
drag.current = null;
const el = event.currentTarget;
el.classList.remove(styles.dragging);
if (Math.abs(current.dx) > 80) {
el.style.setProperty("--dx", `${Math.sign(current.dx) * 360}px`);
dismiss(current.id);
} else {
el.style.setProperty("--dx", "0px");
}
};
let position = 0;
return (
<div className={styles.root}>
<button type="button" className={styles.button} onClick={add}>
Send offer
</button>
<p className={styles.hint}>Hover the stack to expand it, swipe a toast away</p>
<ol className={styles.region} role="status" aria-live="polite" aria-label="Notifications">
{toasts.map((t, fullIndex) => {
const i = t.leaving ? fullIndex : position++;
return (
<li
key={t.id}
className={`${styles.toast} ${t.leaving ? styles.leaving : ""}`}
style={{ "--i": i } as React.CSSProperties}
data-hidden={i > 2 || undefined}
onPointerDown={down(t.id)}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 16 16"><path d="M3.5 8.5 6.5 11.5 12.5 5" /></svg>
</span>
<span className={styles.text}>
<b>{t.title}</b>
<span>{t.body}</span>
</span>
<button type="button" className={styles.close} aria-label={`Dismiss: ${t.title}`} onClick={() => dismiss(t.id)}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
{!t.leaving && (
<i
className={styles.bar}
style={{ animationDuration: `${LIFE}ms` }}
onAnimationEnd={() => dismiss(t.id)}
aria-hidden="true"
/>
)}
</li>
);
})}
</ol>
</div>
);
}
demo.module.css
.root { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; padding: 18px 16px; overflow: hidden; }
.button { min-height: 44px; padding: 0 22px; border: 0; border-radius: 999px; background: var(--ink); color: var(--white); font-weight: 600; font-size: 15px; cursor: pointer; transition: background-color .18s, scale .18s var(--out); }
.button:hover { background: var(--ink-hover); }
.button:active { scale: .97; }
.hint { margin-top: 8px; font-size: 13px; color: var(--muted); text-align: center; }
.region { position: absolute; left: 50%; bottom: 16px; width: min(340px, calc(100% - 32px)); height: 58px; translate: -50% 0; margin: 0; padding: 0; list-style: none; }
.toast {
--dx: 0px;
position: absolute; left: 0; right: 0; bottom: 0; z-index: calc(10 - var(--i));
display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px;
min-height: 58px; padding: 8px 6px 8px 12px; border: 1px solid var(--line); border-radius: 12px;
background: var(--white); box-shadow: 0 8px 22px color-mix(in srgb, var(--ink) 10%, transparent);
transform-origin: 50% 100%;
transform: translateY(calc(var(--i) * -10px)) scale(calc(1 - var(--i) * .05));
translate: var(--dx) 0;
touch-action: pan-y; user-select: none; cursor: grab;
transition: transform .4s var(--out), translate .3s var(--out), opacity .24s;
animation: enter .45s var(--out);
}
/* Covers the gap between expanded toasts so the stack does not collapse mid hover. */
.toast::after { content: ""; position: absolute; left: 0; right: 0; top: 100%; height: 10px; }
.region:hover .toast, .region:focus-within .toast { transform: translateY(calc(var(--i) * (-100% - 8px))); }
.toast[data-hidden] { opacity: 0; pointer-events: none; }
.dragging { cursor: grabbing; transition: transform .4s var(--out), opacity .24s; }
.leaving { opacity: 0; pointer-events: none; }
@keyframes enter { from { opacity: 0; translate: 0 24px; } }
.icon { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: var(--accent-pale); }
.icon svg { width: 16px; height: 16px; fill: none; stroke: var(--accent-text); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.text { display: grid; min-width: 0; line-height: 1.3; }
.text b { font-size: 14px; }
.text span { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.close { display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; }
.close:hover { background: var(--surface); color: var(--ink); }
.close svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
.bar { position: absolute; left: 12px; right: 12px; bottom: 3px; height: 2px; border-radius: 2px; background: var(--accent); transform-origin: left; animation: drain linear forwards; }
.toast:not(:first-child) .bar { opacity: 0; }
.region:hover .bar, .region:focus-within .bar { animation-play-state: paused; opacity: 1; }
@keyframes drain { to { transform: scaleX(0); } }
@media (prefers-reduced-motion: reduce) {
.toast { animation: none; transition: opacity .01s; }
}
Use it for
- Confirming background actions such as a sent offer or a synced calendar
- Apps where several notices can arrive close together
Avoid
- Errors the user must act on, which belong next to the field or in a dialog
- More than three or four visible at once: summarise instead
How it works
Each toast gets its stack index as a CSS variable, and transform uses it to shrink and lift older toasts. Hovering or focusing the list switches the transform to a full height offset per index, so the stack fans out. The countdown is a CSS animation on a 2px line; animationend dismisses the toast, so pausing it with animation-play-state on hover pauses the timer too. Swipe is pointer capture plus a translate variable; past 80px the toast flies out.
Accessibility
The list is a polite live region, so new toasts are announced. Every toast has a real close button with a label, and focus inside the stack expands it and pauses every countdown. Reduced motion keeps the stack but drops the slide and scale transitions.