Skip to content
Interface

Toast

Notifications stack into a deck, newest in front, and fan apart when you hover or focus them. Two details most implementations miss: the live region is permanently mounted and empty, because aria-live only announces changes to a region that already existed — mounting the region and its content together usually announces nothing at all. And the dismiss timers pause on hover AND on focus, banking elapsed time so resuming does not restart the full duration. A toast that dismisses itself while you are reading it is worse than no toast.

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Toast } from "@/components/toast"; <Toast />

Customise

4000ms
3

Older ones collapse behind the deck rather than disappearing.

The component

components/craft/interface/Toast.tsx

/* Deepak Kumar E — https://craft.iam-deepak.space */ import {  createContext,  useCallback,  useContext,  useEffect,  useRef,  useState,} from "react"; /** * Stacked notifications that collapse into a deck and fan out on hover. * * Two things most toast implementations get wrong: * * THE LIVE REGION IS PERMANENT. `aria-live` only announces changes to a region * that already exists — mounting a region and its content at the same moment * usually announces nothing, because there was no prior state to diff against. * The container here is always mounted, empty, and only its children change. * * THE TIMER PAUSES ON HOVER AND ON FOCUS. A toast that dismisses itself while * you are reading it, or while your keyboard focus is inside it reaching for * its action, is worse than no toast. Elapsed time is tracked explicitly so * resuming does not restart the full duration. * * `role="status"` rather than `role="alert"`: alert is assertive and * interrupts whatever the screen reader is saying, which is right for an * error and rude for "Copied". */ export interface ToastItem {  id: number;  message: string;  tone: "info" | "success" | "error";} let nextId = 0; export type PushToast = (message: string, tone?: ToastItem["tone"]) => void; const ToastContext = createContext<PushToast | null>(null); /** * Anything inside <Toast> can raise one. * * Context rather than a render prop. A render prop reads cleaner in a demo, * but it hands the push function down during render, and anything below has * to thread it through by hand. A hook keeps the call site next to the event * that triggers it, which is the only place a toast should ever be raised. */export function useToast(): PushToast {  const push = useContext(ToastContext);  if (!push) {    throw new Error("useToast must be called inside a <Toast> boundary.");  }  return push;} export interface ToastProps {  /** Milliseconds before auto-dismiss. */  duration?: number;  /** How many stay visible; older ones collapse behind the deck. */  max?: number;  accent?: string;  still?: boolean;  /** Anything inside can raise a toast with useToast(). */  children?: React.ReactNode;  className?: string;} export function Toast({  duration = 4000,  max = 3,  accent = "#c3f53c",  still = false,  children,  className = "",}: ToastProps) {  const [items, setItems] = useState<ToastItem[]>([]);  const [paused, setPaused] = useState(false);  const timers = useRef(    new Map<number, { started: number; elapsed: number; handle: number }>(),  );   const dismiss = useCallback((id: number) => {    const timer = timers.current.get(id);    if (timer) window.clearTimeout(timer.handle);    timers.current.delete(id);    setItems((current) => current.filter((t) => t.id !== id));  }, []);   const schedule = useCallback(    (id: number, remaining: number) => {      const handle = window.setTimeout(() => dismiss(id), remaining);      timers.current.set(id, {        started: performance.now(),        elapsed: timers.current.get(id)?.elapsed ?? 0,        handle,      });    },    [dismiss],  );   const push = useCallback(    (message: string, tone: ToastItem["tone"] = "info") => {      nextId += 1;      const id = nextId;      setItems((current) =>        [...current, { id, message, tone }].slice(-max - 2),      );      schedule(id, duration);    },    [duration, max, schedule],  );   // Pause every timer while the deck is hovered or holds focus, banking the  // time already served so resuming does not restart the full duration.  useEffect(() => {    if (!paused) return;    // Captured once: the cleanup must operate on the same Map the pause    // banked its elapsed times into.    const map = timers.current;     map.forEach((timer, id) => {      window.clearTimeout(timer.handle);      map.set(id, {        ...timer,        elapsed: timer.elapsed + (performance.now() - timer.started),      });    });     return () => {      map.forEach((timer, id) => {        schedule(id, Math.max(400, duration - timer.elapsed));      });    };  }, [paused, duration, schedule]);   useEffect(() => {    // Copied out of the ref: by the time cleanup runs, timers.current may    // point at a different Map.    const map = timers.current;    return () => map.forEach((t) => window.clearTimeout(t.handle));  }, []);   const visible = items.slice(-max);   return (    <ToastContext.Provider value={push}>      <div className={className}>        {children}         {/*        Always mounted, even with nothing in it. A live region added to the DOM        at the same time as its content usually announces nothing at all.      */}        <div          role="status"          aria-live="polite"          aria-relevant="additions"          onPointerEnter={() => setPaused(true)}          onPointerLeave={() => setPaused(false)}          onFocusCapture={() => setPaused(true)}          onBlurCapture={() => setPaused(false)}          className="pointer-events-none mt-6 flex min-h-[4.5rem] flex-col items-center"        >          <div className="pointer-events-auto relative w-full max-w-sm">            {visible.map((item, index) => {              // Newest sits in front at full size; older ones scale down and              // tuck behind, so the stack reads as depth rather than a list.              const fromTop = visible.length - 1 - index;              const tone =                item.tone === "error"                  ? "#f97066"                  : item.tone === "success"                    ? accent                    : "#94a3b8";               return (                <div                  key={item.id}                  className="absolute inset-x-0 top-0 flex items-center justify-between gap-3 rounded-lg border border-ink-500 bg-ink-800 px-4 py-3 shadow-xl"                  style={{                    transform: paused                      ? `translateY(${fromTop * 58}px)`                      : `translateY(${fromTop * 9}px) scale(${1 - fromTop * 0.05})`,                    opacity: fromTop > 2 ? 0 : 1,                    zIndex: index,                    transition: still                      ? "none"                      : "transform 380ms cubic-bezier(0.16,1,0.3,1), opacity 240ms linear",                  }}                >                  <span className="flex items-center gap-2.5 text-sm text-bone-200">                    <span                      aria-hidden                      className="h-1.5 w-1.5 shrink-0 rounded-full"                      style={{ background: tone }}                    />                    {item.message}                  </span>                  <button                    type="button"                    onClick={() => dismiss(item.id)}                    className="shrink-0 font-mono text-micro text-bone-500 transition-colors hover:text-bone-100"                  >                    Dismiss                  </button>                </div>              );            })}          </div>        </div>      </div>    </ToastContext.Provider>  );} 

One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.

What it costs

Weight

+2.7KB gzipped · no new dependencies

Runs on

Compositor

Animates transform and opacity only, so the compositor handles it off the main thread. Scroll and input stay responsive even while it runs.

Reduced motion

Toasts appear and leave without transition, and the deck does not animate as it fans. Position and stacking still communicate order.

Accessibility
  • role="status" with aria-live="polite" — announced without interrupting, unlike role="alert".
  • The live region is always mounted and empty, so additions are actually announced.
  • Timers pause on focus as well as hover, so a keyboard user reaching the dismiss button is not raced.
  • Every toast carries a real Dismiss button rather than relying on the timer.
Watch out
  • Uses a render prop to hand you push(). For app-wide toasts, lift that into a context — this component deliberately does not create a global singleton.
  • The deck is positioned within its own container, not portalled to the body. Put it where you want it, or wrap it in your own fixed positioning.