Toast
Toaster + toast(): call toast.success("Saved") from anywhere, render <Toaster /> once. The front toast is full size, the ones behind fold into slivers, hover springs them apart (and pauses the timers), a swipe right dismisses.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// Toaster + toast(): call toast.success("Saved") from anywhere, render <Toaster /> once. The front toast is full
// size, the ones behind fold into slivers, hover springs them apart (and pauses the timers), a swipe right dismisses.
// Only the first mounted <Toaster /> draws, so a toast never shows twice.
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
export type ToastKind = "info" | "success" | "error" | "warning";
export type ToasterCue = "arrive" | "done" | "caution" | "push" | "swipe";
export type ToastInput = {
title: ReactNode;
body?: ReactNode;
kind?: ToastKind;
action?: { label: string; onClick: () => void };
/** ms before it dismisses itself; 0 keeps it until dismissed. */
duration?: number;
};
type T = ToastInput & { id: number };
const subs = new Set<(t: T[]) => void>();
let list: T[] = [];
let seq = 0;
const emit = () => subs.forEach((f) => f([...list]));
/** Imperative API: toast({ title, kind }) · toast.success("Saved") · toast.dismiss(id) */
export const toast = Object.assign(
(t: ToastInput) => {
const item = { id: ++seq, kind: "info" as ToastKind, duration: 4500, ...t };
list = [item, ...list];
emit();
return item.id;
},
{
success: (title: ReactNode, body?: ReactNode) => toast({ title, body, kind: "success" }),
error: (title: ReactNode, body?: ReactNode) => toast({ title, body, kind: "error" }),
info: (title: ReactNode, body?: ReactNode) => toast({ title, body, kind: "info" }),
warning: (title: ReactNode, body?: ReactNode) => toast({ title, body, kind: "warning" }),
dismiss: (id?: number) => {
list = id === undefined ? [] : list.filter((t) => t.id !== id);
emit();
},
},
);
// Mounted toasters in mount order; the first is the one that draws.
const hosts: object[] = [];
const hostSubs = new Set<() => void>();
const hostChanged = () => hostSubs.forEach((f) => f());
export type ToasterProps = {
/** Corner (or edge centre) the stack sits in. */
position?: "bottom-right" | "bottom-center" | "top-right" | "top-center";
/** Sound hook for each new toast: "done" success, "caution" error or warning, "arrive" otherwise; "push" when dismissed by its X, "swipe" when swiped away. */
onSound?: (cue: ToasterCue) => void;
/** Accessible name of each toast's dismiss button. */
dismissLabel?: string;
/** Static toasts drawn in place as an open list, apart from toast(); for previews. */
preview?: ToastInput[];
className?: string;
style?: CSSProperties;
};
const ICON = { success: CheckCircle2, info: Info, error: XCircle, warning: AlertTriangle };
const CARD_HEIGHT = 64;
const CARD_GAP = 10;
const PEEK = 12;
const MAX_SHOWN = 3;
const SNAP = { type: "spring", stiffness: 380, damping: 34, mass: 0.8 } as const;
export function Toaster({ position = "bottom-right", onSound, dismissLabel = "Dismiss", preview, className, style }: ToasterProps) {
const [lead, setLead] = useState(false);
const [items, setItems] = useState<T[]>([]);
const [kept, setKept] = useState(() => preview?.map((t, i): T => ({ kind: "info", ...t, id: -1 - i })));
const [hover, setOpen] = useState(false);
const open = hover || !!kept;
const drop = (id: number) => (kept ? setKept(kept.filter((t) => t.id !== id)) : toast.dismiss(id));
const timers = useRef(new Map<number, number>());
const seen = useRef(new Set<number>(list.map((t) => t.id)));
const root = useRef<HTMLDivElement>(null);
const snd = useRef(onSound);
snd.current = onSound;
const top = position.startsWith("top");
const still = useReducedMotion();
useEffect(() => {
if (preview) return; // a preview never takes the queue
const me = {};
const sync = () => setLead(hosts[0] === me);
hosts.push(me);
hostSubs.add(sync);
hostChanged();
return () => {
hosts.splice(hosts.indexOf(me), 1);
hostSubs.delete(sync);
hostChanged();
};
}, [preview]);
useEffect(() => {
if (!lead) return;
setItems([...list]);
subs.add(setItems);
return () => void subs.delete(setItems);
}, [lead]);
// the button that was pressed leaves with its toast: hand focus to a neighbour's dismiss button, or fold the stack when none is left
const handOff = (btn: HTMLElement) => {
const xs = [...(root.current?.querySelectorAll<HTMLElement>(".tos-x") ?? [])];
const at = xs.indexOf(xs.find((x) => x.parentElement === btn.parentElement) ?? btn);
const next = xs[at + 1] ?? xs[at - 1];
if (next) next.focus();
else setOpen(false);
};
const runAction = (t: T, btn: HTMLElement) => {
handOff(btn);
t.action?.onClick();
drop(t.id);
};
const dismissByHand = (id: number, btn: HTMLElement) => {
handOff(btn);
snd.current?.("push");
drop(id);
};
// one cue per new toast
useEffect(() => {
if (!lead) return;
for (const t of items) {
if (seen.current.has(t.id)) continue;
seen.current.add(t.id);
snd.current?.(t.kind === "error" || t.kind === "warning" ? "caution" : t.kind === "success" ? "done" : "arrive");
}
}, [items, lead]);
// auto-dismiss, paused while expanded
useEffect(() => {
if (!lead) return;
const tm = timers.current;
for (const t of items) {
if (open || tm.has(t.id) || !t.duration) continue;
const timer = window.setTimeout(() => {
tm.delete(t.id);
toast.dismiss(t.id);
}, t.duration);
tm.set(t.id, timer);
}
if (open) {
tm.forEach((h) => clearTimeout(h));
tm.clear();
}
}, [items, open, lead]);
useEffect(() => {
const tm = timers.current;
return () => {
tm.forEach((h) => clearTimeout(h));
tm.clear();
};
}, []);
// a manual popover lives in the top layer: it needs no portal and no z-index, and sits above any ancestor transform
useEffect(() => {
try {
root.current?.showPopover?.();
} catch {
// already open, or a preview
}
});
if (!lead && !kept) return null;
const shown = (kept ?? items).slice(0, MAX_SHOWN);
const n = shown.length;
return (
<div
ref={root}
popover={kept ? undefined : "manual"}
className={className ? `tos-root ${className}` : "tos-root"}
data-pos={kept ? undefined : position}
data-open={open || undefined}
style={{ height: open ? n * (CARD_HEIGHT + CARD_GAP) : CARD_HEIGHT + (n - 1) * PEEK, ...style }}
onPointerEnter={() => setOpen(true)}
onPointerLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={(e) => !root.current?.contains(e.relatedTarget as Node) && setOpen(false)}
>
<AnimatePresence initial={false}>
{shown.map((t, i) => {
const Icon = ICON[t.kind ?? "info"];
const sign = top ? 1 : -1;
return (
<motion.div
key={t.id}
layout
className="tos-toast"
data-kind={t.kind}
style={{ zIndex: MAX_SHOWN - i, height: CARD_HEIGHT, [top ? "top" : "bottom"]: 0 }}
initial={{ opacity: 0, y: -sign * 24, scale: 0.96, filter: "blur(6px)" }}
animate={{ opacity: open ? 1 : 1 - i * 0.18, y: open ? sign * i * (CARD_HEIGHT + CARD_GAP) : sign * i * PEEK, scale: open ? 1 : 1 - i * 0.05, filter: "blur(0px)", transitionEnd: { filter: "none" } }}
exit={{ opacity: 0, x: 60, transition: { duration: still ? 0 : 0.18, ease: [0.4, 0, 1, 1] } }}
transition={still ? { duration: 0 } : { ...SNAP, opacity: { duration: 0.16 }, filter: { duration: 0.2 } }}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={{ left: 0.08, right: 0.6 }}
dragSnapToOrigin
onDragEnd={(_, info) => (info.offset.x > 90 || info.velocity.x > 500) && (snd.current?.("swipe"), drop(t.id))}
aria-hidden={!open && i !== 0 ? true : undefined}
role={t.kind === "error" ? "alert" : "status"}
>
<span className="tos-icon">
<Icon />
</span>
<span className="tos-text">
<b>{t.title}</b>
{t.body && <span>{t.body}</span>}
</span>
{t.action && (
<button type="button" className="tos-act" tabIndex={open || i === 0 ? 0 : -1} onClick={(e) => runAction(t, e.currentTarget)}>
{t.action.label}
</button>
)}
<button type="button" className="tos-x" aria-label={dismissLabel} tabIndex={open || i === 0 ? 0 : -1} onClick={(e) => dismissByHand(t.id, e.currentTarget)}>
<X />
</button>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}
/* Toaster. Prefix tos-. A manual popover (top layer) holding a stack of absolutely placed cards. */
.tos-root,
.tos-root * {
box-sizing: border-box;
}
:where(.tos-root) button { margin: 0; padding: 0; border: 0; background: none; font: inherit; color: inherit; }
.tos-root {
--tos-out: cubic-bezier(.22,1,.36,1);
--tos-card: var(--ts-raised);
position: fixed;
inset: auto;
z-index: 50;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
overflow: visible;
width: min(360px, calc(100vw - 32px));
font-family: var(--ts-font, system-ui, sans-serif);
pointer-events: none;
transition: height 420ms var(--tos-out);
}
[data-theme="dark"] .tos-root {
--tos-card: var(--ts-tint-3);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .tos-root {
--tos-card: var(--ts-tint-3);
}
}
.tos-root[data-pos$="right"] {
right: 16px;
}
.tos-root[data-pos$="center"] {
left: 50%;
translate: -50% 0;
}
.tos-root[data-pos^="bottom"] {
bottom: 16px;
}
.tos-root[data-pos^="top"] {
top: 16px;
}
/* a preview sits in the flow */
.tos-root:not([data-pos]) {
position: relative;
}
/* a card */
.tos-toast {
position: absolute;
inset-inline: 0;
display: flex;
align-items: center;
gap: 12px;
padding-inline: 14px 10px;
border-radius: var(--ts-r-pop);
background: var(--tos-card);
color: var(--ts-fg);
box-shadow: var(--ts-edge);
transform-origin: 50% 100%;
pointer-events: auto;
touch-action: pan-y;
cursor: grab;
}
.tos-root[data-pos^="top"] .tos-toast {
transform-origin: 50% 0;
}
.tos-icon {
display: grid;
flex: none;
color: var(--ts-fg-muted);
}
.tos-icon svg {
width: 18px;
height: 18px;
}
.tos-toast[data-kind="success"] .tos-icon {
color: var(--ts-success);
}
.tos-toast[data-kind="error"] .tos-icon {
color: var(--ts-danger);
}
.tos-toast[data-kind="warning"] .tos-icon {
color: var(--ts-warning);
}
/* text: bold title over a muted one-line body, both clipped with an ellipsis */
.tos-text {
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.tos-text > * {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tos-text b {
font-size: 13px;
font-weight: 500;
letter-spacing: -.01em;
}
.tos-text span {
font-size: 12px;
color: var(--ts-fg-muted);
}
/* buttons */
.tos-act,
.tos-x {
cursor: pointer;
transition: scale 220ms var(--tos-out), background 160ms var(--tos-out), color 160ms var(--tos-out);
}
.tos-act {
height: 30px;
padding-inline: 10px;
border-radius: 999px;
background: var(--ts-fg);
color: var(--ts-fg-inverse);
font-size: 12px;
font-weight: 500;
}
.tos-x {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
color: var(--ts-fg-faint);
}
.tos-x svg {
width: 15px;
height: 15px;
}
.tos-x:hover {
background: rgba(var(--ts-fg-rgb), .06);
color: var(--ts-fg);
}
.tos-act:active {
scale: .94;
}
.tos-x:active {
scale: .9;
}
.tos-act:focus-visible,
.tos-x:focus-visible {
outline: 2px solid var(--ts-focus);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.tos-root {
transition: none;
}
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { Toaster } from "./Toaster";
import "./Toaster.css";
export default function Demo() {
return <Toaster />;
}