Swish UI
All componentsFeedback and forms Free

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.

Found on