Overview

A craft toast system inspired by Sonner’s principles: mount <Toaster /> once, call toast() from anywhere, enter and exit the same bottom edge, swipe down to dismiss, pause timers on hover / tab hidden, and expand the stack on hover.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/toast-stack.json

Install dependencies:

$ pnpm add motion lucide-react

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/toast-stack.tsx
tsx
"use client";
import React, {  useCallback,  useEffect,  useRef,  useState,  useSyncExternalStore,} from "react";import { createPortal } from "react-dom";import {  AnimatePresence,  motion,  useMotionValue,  useReducedMotion,  animate,} from "motion/react";import { X } from "lucide-react";import { cn } from "@/lib/utils";
export type ToastTone = "default" | "success" | "error";
export type ToastInput = {  title: string;  description?: string;  tone?: ToastTone;  duration?: number;};
type ToastRecord = ToastInput & {  id: string;  duration: number;  remaining: number;  createdAt: number;};
type Listener = () => void;
let toasts: ToastRecord[] = [];let limitCap = 3;const listeners = new Set<Listener>();
function emit() {  listeners.forEach((l) => l());}
function subscribe(listener: Listener) {  listeners.add(listener);  return () => listeners.delete(listener);}
function getSnapshot() {  return toasts;}
const EMPTY_TOASTS: ToastRecord[] = [];
function getServerSnapshot(): ToastRecord[] {  return EMPTY_TOASTS;}
function makeId() {  if (typeof crypto !== "undefined" && crypto.randomUUID) {    return crypto.randomUUID();  }  return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;}
function pushToast(input: ToastInput | string, tone?: ToastTone) {  const normalized: ToastInput =    typeof input === "string" ? { title: input, tone } : { ...input, tone: input.tone ?? tone };  const duration = normalized.duration ?? 4000;  const record: ToastRecord = {    ...normalized,    id: makeId(),    duration,    remaining: duration,    createdAt: Date.now(),  };  toasts = [record, ...toasts].slice(0, limitCap);  emit();  return record.id;}
function removeToast(id: string) {  toasts = toasts.filter((t) => t.id !== id);  emit();}
export function dismiss(id: string) {  removeToast(id);}
type ToastFn = {  (input: ToastInput): string;  success: (title: string, description?: string) => string;  error: (title: string, description?: string) => string;  message: (title: string, description?: string) => string;};
export const toast: ToastFn = Object.assign(  (input: ToastInput) => pushToast(input),  {    success: (title: string, description?: string) =>      pushToast({ title, description, tone: "success" }),    error: (title: string, description?: string) =>      pushToast({ title, description, tone: "error" }),    message: (title: string, description?: string) =>      pushToast({ title, description, tone: "default" }),  },);
const STACK_GAP = 14;const TOAST_H = 64;const DISMISS_DY = 48;const DISMISS_V = 0.11;
function readToastCloseMs() {  const v = parseFloat(    getComputedStyle(document.documentElement).getPropertyValue("--toast-close"),  );  return Number.isFinite(v) ? v : 250;}
export type ToasterProps = {  limit?: number;  position?: "bottom-center" | "bottom-right";  className?: string;};
export function Toaster({  limit = 3,  position = "bottom-center",  className,}: ToasterProps) {  const reduce = useReducedMotion();  const [mounted, setMounted] = useState(false);  const [expanded, setExpanded] = useState(false);  const [paused, setPaused] = useState(false);  const items = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
  useEffect(() => {    setMounted(true);  }, []);
  useEffect(() => {    limitCap = limit;    if (toasts.length > limit) {      toasts = toasts.slice(0, limit);      emit();    }  }, [limit]);
  useEffect(() => {    const onVis = () => {      if (document.visibilityState === "hidden") setPaused(true);      else setPaused(false);    };    document.addEventListener("visibilitychange", onVis);    return () => document.removeEventListener("visibilitychange", onVis);  }, []);
  if (!mounted) return null;
  return createPortal(    <div      className={cn(        "pointer-events-none fixed inset-x-0 bottom-0 z-80 flex p-4 sm:p-6",        position === "bottom-right"          ? "justify-end"          : "justify-center sm:justify-center",        className,      )}      aria-live="polite"      aria-relevant="additions removals"    >      <div        className={cn(          "relative w-full max-w-[360px]",          // Empty host must not steal clicks from page chrome (e.g. bottom nav).          items.length > 0 ? "pointer-events-auto" : "pointer-events-none",        )}        style={{          minHeight:            items.length === 0              ? 0              : Math.max(                  TOAST_H + 24,                  expanded                    ? items.length * (TOAST_H + STACK_GAP)                    : TOAST_H + Math.min(items.length - 1, 2) * 10,                ),        }}        onMouseEnter={() => {          if (items.length === 0) return;          setExpanded(true);          setPaused(true);        }}        onMouseLeave={() => {          setExpanded(false);          if (document.visibilityState !== "hidden") setPaused(false);        }}        onFocusCapture={() => {          if (items.length === 0) return;          setExpanded(true);          setPaused(true);        }}        onBlurCapture={(e) => {          if (!e.currentTarget.contains(e.relatedTarget as Node)) {            setExpanded(false);            if (document.visibilityState !== "hidden") setPaused(false);          }        }}      >        <AnimatePresence initial={false}>          {items.map((t, index) => (            <ToastCard              key={t.id}              toast={t}              index={index}              total={items.length}              expanded={expanded}              paused={paused}              reduce={!!reduce}              onDismiss={() => removeToast(t.id)}            />          ))}        </AnimatePresence>      </div>    </div>,    document.body,  );}
function ToastCard({  toast: t,  index,  total,  expanded,  paused,  reduce,  onDismiss,}: {  toast: ToastRecord;  index: number;  total: number;  expanded: boolean;  paused: boolean;  reduce: boolean;  onDismiss: () => void;}) {  const y = useMotionValue(0);  const [dragging, setDragging] = useState(false);  const [open, setOpen] = useState(false);  const [exiting, setExiting] = useState(false);  const samples = useRef<{ t: number; y: number }[]>([]);  const remainingRef = useRef(t.remaining);  const lastTick = useRef<number | null>(null);  const exitTimer = useRef<number | null>(null);
  useEffect(() => {    remainingRef.current = t.remaining;  }, [t.id, t.remaining]);
  // Enter via .t-toast.is-open  useEffect(() => {    const id = requestAnimationFrame(() => setOpen(true));    return () => cancelAnimationFrame(id);  }, []);
  const beginExit = useCallback(() => {    if (exiting) return;    setExiting(true);    setOpen(false);    if (reduce) {      onDismiss();      return;    }    const ms = readToastCloseMs();    exitTimer.current = window.setTimeout(() => {      exitTimer.current = null;      onDismiss();    }, ms + 20);  }, [exiting, onDismiss, reduce]);
  useEffect(() => {    return () => {      if (exitTimer.current) window.clearTimeout(exitTimer.current);    };  }, []);
  useEffect(() => {    if (t.duration <= 0) return;    let raf = 0;
    const loop = (now: number) => {      if (paused || dragging || exiting) {        lastTick.current = now;        raf = requestAnimationFrame(loop);        return;      }      if (lastTick.current == null) lastTick.current = now;      const delta = now - lastTick.current;      lastTick.current = now;      remainingRef.current -= delta;      if (remainingRef.current <= 0) {        beginExit();        return;      }      raf = requestAnimationFrame(loop);    };
    raf = requestAnimationFrame(loop);    return () => cancelAnimationFrame(raf);  }, [paused, dragging, exiting, beginExit, t.duration, t.id]);
  const front = index === 0;  const offsetY = expanded ? -index * (TOAST_H + STACK_GAP) : -index * 12;  const scale = expanded ? 1 : 1 - Math.min(index, 2) * 0.05;
  const settle = useCallback(    (vy: number) => {      animate(y, 0, {        type: "spring",        bounce: 0,        duration: 0.35,        velocity: vy,      });    },    [y],  );
  const flingOut = useCallback(    (vy: number) => {      const target =        typeof window !== "undefined" ? window.innerHeight : 640;      animate(y, target, {        type: "spring",        bounce: 0,        duration: 0.38,        velocity: vy,        onComplete: onDismiss,      });    },    [y, onDismiss],  );
  const toneBorder =    t.tone === "success"      ? "border-emerald-600/30"      : t.tone === "error"        ? "border-red-600/30"        : "border-(--color-rule)";
  return (    <motion.div      layout      // Stack offsets only — enter/exit owned by .t-toast      animate={{        y: offsetY,        scale,        zIndex: total - index,        opacity: front || expanded ? 1 : Math.max(0.55, 1 - index * 0.2),      }}      exit={{ opacity: 0, transition: { duration: 0.01 } }}      transition={        reduce          ? { duration: 0.15 }          : { type: "spring" as const, bounce: 0, duration: 0.3 }      }      className={cn(        "absolute inset-x-0 bottom-0 w-full",        !front && !expanded && "pointer-events-none",      )}    >      <div        className={cn(          "t-toast rounded-lg border bg-(--color-paper)/95",          "shadow-[0_12px_40px_rgba(0,0,0,0.12)] backdrop-blur-xl",          "dark:shadow-[0_12px_40px_rgba(0,0,0,0.45)]",          toneBorder,          open && "is-open",        )}      >        <motion.div          style={{ y }}          className="touch-none select-none px-4 py-3"          onPointerDown={(e) => {            if (reduce || !front || exiting) return;            (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);            setDragging(true);            y.set(0);            samples.current = [{ t: performance.now(), y: e.clientY }];          }}          onPointerMove={(e) => {            if (!dragging || reduce) return;            const first = samples.current[0];            if (!first) return;            const dy = Math.max(0, e.clientY - first.y);            y.set(dy);            samples.current.push({ t: performance.now(), y: e.clientY });            if (samples.current.length > 6) samples.current.shift();          }}          onPointerUp={(e) => {            if (!dragging || reduce) return;            setDragging(false);            try {              (e.currentTarget as HTMLElement).releasePointerCapture(                e.pointerId,              );            } catch {              /* noop */            }            const arr = samples.current;            const last = arr[arr.length - 1];            const prev = arr[Math.max(0, arr.length - 3)];            const dt = Math.max(16, (last?.t ?? 0) - (prev?.t ?? 0));            const vy = ((last?.y ?? 0) - (prev?.y ?? 0)) / dt;            const dy = y.get();            if (dy > DISMISS_DY || vy > DISMISS_V) {              flingOut(vy * 1000);            } else {              settle(vy * 1000);            }          }}        >          <div className="flex items-start gap-3">            <div className="min-w-0 flex-1">              <p className="text-sm font-medium text-(--color-ink)">                {t.title}              </p>              {t.description ? (                <p className="mt-0.5 text-xs leading-relaxed text-(--color-ink-muted)">                  {t.description}                </p>              ) : null}            </div>            <button              type="button"              aria-label="Dismiss"              onClick={beginExit}              className={cn(                "flex size-10 shrink-0 items-center justify-center rounded-sm",                "text-(--color-ink-muted) outline-none",                "transition-[transform,color] duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",                "hover:text-(--color-ink) active:scale-[0.96]",                "focus-visible:ring-2 focus-visible:ring-(--color-focus)",              )}            >              <X className="size-3.5 stroke-[1.5]" />            </button>          </div>        </motion.div>      </div>    </motion.div>  );}
/** @deprecated Use `<Toaster />` + `toast()` instead */export function ToastStackProvider({  children,  limit = 3,  className,}: {  children?: React.ReactNode;  limit?: number;  className?: string;}) {  return (    <>      {children}      <Toaster limit={limit} className={className} />    </>  );}
/** @deprecated Use `toast` from this module */export function useToastStack() {  return {    toast: (input: ToastInput) => pushToast(input),    dismiss,  };}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { Toaster, toast } from "@/components/ui/toast-stack";
// Once in your layout / page<Toaster position="bottom-center" limit={3} />
// Anywhere — no hook requiredtoast.success("Invite sent", "Mira will get access.");toast.error("Deploy failed");toast({ title: "Saved", tone: "default" });

Props

PropTypeRequiredDefaultDescription
limitnumberNo3Max visible toasts in the stack.
position"bottom-center" | "bottom-right"No"bottom-center"Viewport placement (matches enter edge).
toast()(input: ToastInput) => stringNoPush a toast; returns its id. Also toast.success / toast.error / toast.message.
dismiss()(id: string) => voidNoRemove a toast by id.

Best Practices

  1. Enter and exit from the same edge — swipe dismisses downward toward that edge.
  2. Timers pause on hover, focus, and document.hidden — resume when idle again.
  3. Enter uses a ≤300ms spring (bounce: 0); dismiss control is ≥40×40.
  4. Prefer toast.success / toast.error helpers for tone defaults.