Toast Notifications

Stacking toasts with layout animation and auto-dismiss.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/toast-stack.tsx

    components/ui/toast-stack.tsx
    "use client";
    
    import { AnimatePresence, motion } from "motion/react";
    import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react";
    import { useCallback, useState } from "react";
    
    type ToastKind = "success" | "info" | "warning";
    type Toast = { id: number; kind: ToastKind; title: string; text: string };
    
    const icons = {
      success: <CheckCircle2 className="size-5 text-emerald-600" />,
      info: <Info className="size-5 text-blue-600" />,
      warning: <AlertTriangle className="size-5 text-amber-500" />,
    };
    
    export function useToasts(duration = 4000) {
      const [toasts, setToasts] = useState<Toast[]>([]);
      const dismiss = useCallback((id: number) => setToasts((t) => t.filter((x) => x.id !== id)), []);
      const push = useCallback(
        (toast: Omit<Toast, "id">) => {
          const id = Date.now() + Math.random();
          setToasts((t) => [...t.slice(-3), { ...toast, id }]);
          setTimeout(() => dismiss(id), duration);
        },
        [dismiss, duration],
      );
      return { toasts, push, dismiss };
    }
    
    export function ToastStack({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: number) => void }) {
      return (
        <div aria-live="polite" className="pointer-events-none flex w-80 flex-col gap-2">
          <AnimatePresence initial={false}>
            {toasts.map((t) => (
              <motion.div
                key={t.id}
                layout
                initial={{ opacity: 0, y: 20, scale: 0.95 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, x: 60, transition: { duration: 0.2 } }}
                transition={{ type: "spring", stiffness: 380, damping: 30 }}
                className="pointer-events-auto flex items-start gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-lg shadow-zinc-900/5"
              >
                {icons[t.kind]}
                <div className="flex-1">
                  <p className="text-sm font-medium text-zinc-900">{t.title}</p>
                  <p className="text-sm text-zinc-500">{t.text}</p>
                </div>
                <button aria-label="Dismiss" onClick={() => onDismiss(t.id)} className="text-zinc-400 hover:text-zinc-700">
                  <X className="size-4" />
                </button>
              </motion.div>
            ))}
          </AnimatePresence>
        </div>
      );
    }
    
    export default function Demo() {
      const { toasts, push, dismiss } = useToasts();
      return (
        <div className="flex w-full max-w-2xl flex-col items-center gap-6 sm:flex-row sm:items-end sm:justify-between">
          <div className="flex flex-wrap gap-2">
            <button onClick={() => push({ kind: "success", title: "Changes saved", text: "Your profile has been updated." })} className="rounded-lg border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50">
              Success
            </button>
            <button onClick={() => push({ kind: "info", title: "New version", text: "v2.4 is ready to install." })} className="rounded-lg border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50">
              Info
            </button>
            <button onClick={() => push({ kind: "warning", title: "Storage almost full", text: "You've used 92% of your plan." })} className="rounded-lg border border-zinc-200 bg-white px-4 py-2 text-sm font-medium text-zinc-800 hover:bg-zinc-50">
              Warning
            </button>
          </div>
          <div className="flex min-h-56 items-end">
            <ToastStack toasts={toasts} onDismiss={dismiss} />
          </div>
        </div>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.