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;}
function getServerSnapshot(): ToastRecord[] {  return [];}
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 EASE = [0.25, 0.1, 0.25, 1] as const; // soft Sonner-like easeconst ENTER_MS = 0.3;const EXIT_MS = 0.2;const STACK_GAP = 14;const TOAST_H = 64; // approximate for collapsed offsetconst DISMISS_DY = 48;const DISMISS_V = 0.11; // px/ms ≈ Sonner velocity
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]);
  // Pause when tab hidden  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="pointer-events-auto relative w-full max-w-[360px]"        style={{          // Room for stacked + expanded toasts          minHeight: Math.max(            TOAST_H + 24,            expanded              ? items.length * (TOAST_H + STACK_GAP)              : TOAST_H + Math.min(items.length - 1, 2) * 10,          ),        }}        onMouseEnter={() => {          setExpanded(true);          setPaused(true);        }}        onMouseLeave={() => {          setExpanded(false);          if (document.visibilityState !== "hidden") setPaused(false);        }}        onFocusCapture={() => {          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 | null;  onDismiss: () => void;}) {  const y = useMotionValue(0);  const [dragging, setDragging] = useState(false);  const samples = useRef<{ t: number; y: number }[]>([]);  const remainingRef = useRef(t.remaining);  const lastTick = useRef<number | null>(null);
  // Keep remaining in sync when toast identity is same  useEffect(() => {    remainingRef.current = t.remaining;  }, [t.id, t.remaining]);
  useEffect(() => {    if (t.duration <= 0) return;    let raf = 0;
    const loop = (now: number) => {      if (paused || dragging) {        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) {        onDismiss();        return;      }      raf = requestAnimationFrame(loop);    };
    raf = requestAnimationFrame(loop);    return () => cancelAnimationFrame(raf);  }, [paused, dragging, onDismiss, 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      initial={        reduce          ? { opacity: 0 }          : { opacity: 0, y: 24, scale: 0.97 }      }      animate={{        opacity: front || expanded ? 1 : Math.max(0.55, 1 - index * 0.2),        y: offsetY,        scale,        zIndex: total - index,      }}      exit={        reduce          ? { opacity: 0, transition: { duration: 0.12 } }          : {              opacity: 0,              y: 24,              scale: 0.97,              transition: { duration: EXIT_MS, ease: EASE },            }      }      transition={        reduce          ? { duration: 0.15 }          : {              type: "spring" as const,              bounce: 0,              duration: ENTER_MS,            }      }      className={cn(        "absolute inset-x-0 bottom-0",        "w-full",        !front && !expanded && "pointer-events-none",      )}    >      <motion.div        style={{ y }}        className={cn(          "rounded-lg border bg-(--color-paper)/95",          "px-4 py-3 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,          "touch-none select-none",        )}        onPointerDown={(e) => {          if (reduce || !front) 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={onDismiss}            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>    </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.