Overview

Mount <Toaster /> once and call toast() from anywhere. New toasts rise out of a soft blur from the screen edge and stack into a deck; hover or focus fans the deck out to full height and pauses every timer, with a thin progress line showing time left. Swipe a toast toward the edge or sideways to throw it away. Toasts can carry an inline action such as Undo, and toast.promise follows async work from a spinner to a success or error in the same toast, swapping the icon and text in place. Alt+T focuses the newest toast and Esc dismisses it.


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,  useLayoutEffect,  useRef,  useState,  useSyncExternalStore,} from "react";import { createPortal } from "react-dom";import {  AnimatePresence,  animate,  motion,  useMotionValue,  useReducedMotion,  useTransform,} from "motion/react";import {  CircleAlert,  CircleCheck,  LoaderCircle,  TriangleAlert,  X,} from "lucide-react";import { cn } from "@/lib/utils";
/* ---------------------------------- store --------------------------------- */
export type ToastTone = "default" | "success" | "error" | "warning" | "loading";
export type ToastAction = {  label: string;  onClick: () => void;};
export type ToastInput = {  title: string;  description?: string;  tone?: ToastTone;  /** Milliseconds before auto-dismiss. `0` keeps the toast until dismissed. */  duration?: number;  /** A single inline action, e.g. Undo. Clicking it also dismisses the toast. */  action?: ToastAction;  /** Replaces the tone icon. */  icon?: React.ReactNode;  /** Reuse an id to update a toast in place instead of stacking a new one. */  id?: string;};
type ToastRecord = Omit<ToastInput, "id"> & {  id: string;  duration: number;  /** Bumps on every update so timers restart and content cross-fades. */  version: number;};
type Listener = () => void;
let toasts: ToastRecord[] = [];let limitCap = 3;const listeners = new Set<Listener>();const EMPTY: ToastRecord[] = [];
function emit() {  listeners.forEach((l) => l());}
function subscribe(listener: Listener) {  listeners.add(listener);  return () => {    listeners.delete(listener);  };}
function makeId() {  if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();  return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;}
function defaultDuration(tone: ToastTone | undefined) {  if (tone === "loading") return 0;  if (tone === "error") return 6000;  return 4000;}
function upsert(input: ToastInput): string {  const existing = input.id ? toasts.find((t) => t.id === input.id) : undefined;  if (existing) {    const tone = input.tone ?? existing.tone;    const record: ToastRecord = {      ...existing,      ...input,      id: existing.id,      tone,      duration: input.duration ?? defaultDuration(tone),      version: existing.version + 1,    };    toasts = toasts.map((t) => (t.id === record.id ? record : t));  } else {    const record: ToastRecord = {      ...input,      id: input.id ?? makeId(),      duration: input.duration ?? defaultDuration(input.tone),      version: 0,    };    toasts = [record, ...toasts].slice(0, limitCap);  }  emit();  return input.id ?? toasts[0].id;}
/** Remove one toast by id, or every toast when called without one. */export function dismiss(id?: string) {  toasts = id ? toasts.filter((t) => t.id !== id) : [];  emit();}
type Message<T> = string | ((value: T) => string);const resolveMessage = <T,>(m: Message<T>, v: T) => (typeof m === "function" ? m(v) : m);
type ToastFn = {  (input: ToastInput): string;  success: (title: string, description?: string) => string;  error: (title: string, description?: string) => string;  warning: (title: string, description?: string) => string;  message: (title: string, description?: string) => string;  loading: (title: string, description?: string) => string;  /** One toast that follows a promise from loading to success or error. */  promise: <T>(    promise: Promise<T> | (() => Promise<T>),    messages: { loading: string; success: Message<T>; error: Message<unknown> },  ) => string;  update: (id: string, input: Partial<ToastInput>) => string;  dismiss: (id?: string) => void;};
export const toast: ToastFn = Object.assign((input: ToastInput) => upsert(input), {  success: (title: string, description?: string) => upsert({ title, description, tone: "success" }),  error: (title: string, description?: string) => upsert({ title, description, tone: "error" }),  warning: (title: string, description?: string) => upsert({ title, description, tone: "warning" }),  message: (title: string, description?: string) => upsert({ title, description, tone: "default" }),  loading: (title: string, description?: string) => upsert({ title, description, tone: "loading" }),  promise: <T,>(    promise: Promise<T> | (() => Promise<T>),    messages: { loading: string; success: Message<T>; error: Message<unknown> },  ) => {    const id = upsert({ title: messages.loading, tone: "loading" });    const run = typeof promise === "function" ? promise() : promise;    run.then(      (value) => {        if (toasts.some((t) => t.id === id))          upsert({ id, title: resolveMessage(messages.success, value), tone: "success", description: undefined });      },      (error: unknown) => {        if (toasts.some((t) => t.id === id))          upsert({ id, title: resolveMessage(messages.error, error), tone: "error", description: undefined });      },    );    return id;  },  update: (id: string, input: Partial<ToastInput>) => {    const current = toasts.find((t) => t.id === id);    return current ? upsert({ ...current, ...input, id }) : id;  },  dismiss,});
/* --------------------------------- toaster -------------------------------- */
const GAP = 10;const PEEK = 12;const FALLBACK_H = 60;const SPRING = { type: "spring" as const, bounce: 0, duration: 0.38 };
export type ToasterProps = {  /** Most toasts kept at once; the oldest leaves when a new one arrives. */  limit?: number;  position?: "bottom-center" | "bottom-right" | "top-center" | "top-right";  className?: string;};
export function Toaster({ limit = 3, position = "bottom-center", className }: ToasterProps) {  const reduce = !!useReducedMotion();  const items = useSyncExternalStore(subscribe, () => toasts, () => EMPTY);  const [mounted, setMounted] = useState(false);  const [hovered, setHovered] = useState(false);  const [focused, setFocused] = useState(false);  const [heights, setHeights] = useState<Record<string, number>>({});  const listRef = useRef<HTMLOListElement>(null);
  const isTop = position.startsWith("top");  // +1 means "toward the screen edge" is down (bottom toasters), -1 is up.  const edge = isTop ? -1 : 1;  const expanded = (hovered || focused) && items.length > 0;
  useEffect(() => setMounted(true), []);
  useEffect(() => {    limitCap = limit;    if (toasts.length > limit) {      toasts = toasts.slice(0, limit);      emit();    }  }, [limit]);
  // Alt+T jumps to the newest toast, so keyboard users can reach actions.  useEffect(() => {    const onKey = (e: KeyboardEvent) => {      if (e.altKey && e.code === "KeyT") {        const first = listRef.current?.querySelector<HTMLElement>("[data-toast]");        if (first) {          e.preventDefault();          first.focus();        }      }    };    window.addEventListener("keydown", onKey);    return () => window.removeEventListener("keydown", onKey);  }, []);
  // Collapse when the stack empties, so the next toast arrives stacked.  useEffect(() => {    if (items.length === 0) {      setHovered(false);      setFocused(false);    }  }, [items.length]);
  const onHeight = useCallback((id: string, h: number) => {    setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));  }, []);
  if (!mounted) return null;
  const heightOf = (id: string) => heights[id] ?? FALLBACK_H;  const frontH = items[0] ? heightOf(items[0].id) : 0;  const offsets: number[] = [];  let acc = 0;  items.forEach((t, i) => {    offsets.push(expanded ? acc : i * PEEK);    acc += heightOf(t.id) + GAP;  });  const stackH =    items.length === 0      ? 0      : expanded        ? acc - GAP        : frontH + PEEK * (Math.min(items.length, 3) - 1);
  return createPortal(    <section      aria-label="Notifications (Alt+T)"      className={cn(        "pointer-events-none fixed inset-x-0 z-80 flex px-4 sm:px-6",        isTop          ? "top-0 pt-[max(1rem,env(safe-area-inset-top))] sm:pt-6"          : "bottom-0 pb-[max(1rem,env(safe-area-inset-bottom))] sm:pb-6",        position.endsWith("right") ? "justify-center sm:justify-end" : "justify-center",        className,      )}    >      <motion.ol        ref={listRef}        aria-live="polite"        aria-relevant="additions text"        initial={false}        animate={{ height: stackH }}        transition={reduce ? { duration: 0 } : SPRING}        onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}        onPointerLeave={(e) => e.pointerType === "mouse" && setHovered(false)}        // Touch has no hover: a tap on the stack fans it out.        onPointerDown={(e) => e.pointerType !== "mouse" && setHovered(true)}        onFocus={() => setFocused(true)}        onBlur={(e) => {          if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);        }}        className={cn(          "relative m-0 w-full max-w-[360px] list-none p-0",          items.length > 0 ? "pointer-events-auto" : "pointer-events-none",        )}      >        <AnimatePresence initial={false}>          {items.map((t, index) => (            <ToastCard              key={t.id}              toast={t}              index={index}              total={items.length}              offset={offsets[index]}              frontH={frontH}              expanded={expanded}              paused={expanded}              edge={edge}              reduce={reduce}              onHeight={onHeight}            />          ))}        </AnimatePresence>      </motion.ol>    </section>,    document.body,  );}
/* ---------------------------------- card ---------------------------------- */
const TONE_ICON: Record<Exclude<ToastTone, "default">, React.ReactNode> = {  success: <CircleCheck className="text-emerald-500" />,  error: <CircleAlert className="text-red-500" />,  warning: <TriangleAlert className="text-amber-500" />,  loading: <LoaderCircle className="animate-spin text-(--color-neutral)" />,};
const TONE_BAR: Record<ToastTone, string> = {  default: "bg-(--color-ink)/25",  success: "bg-emerald-500/60",  error: "bg-red-500/60",  warning: "bg-amber-500/60",  loading: "bg-transparent",};
function ToastCard({  toast: t,  index,  total,  offset,  frontH,  expanded,  paused,  edge,  reduce,  onHeight,}: {  toast: ToastRecord;  index: number;  total: number;  offset: number;  frontH: number;  expanded: boolean;  paused: boolean;  edge: 1 | -1;  reduce: boolean;  onHeight: (id: string, h: number) => void;}) {  const contentRef = useRef<HTMLDivElement>(null);  const sx = useMotionValue(0);  const sy = useMotionValue(0);  const progress = useMotionValue(1);  const fade = useTransform(() => 1 - Math.min(1, Math.hypot(sx.get(), sy.get()) / 220));  const [dragging, setDragging] = useState(false);  const gesture = useRef<{    x: number;    y: number;    axis: "x" | "y" | null;    samples: { t: number; x: number; y: number }[];  } | null>(null);  const remaining = useRef(t.duration);  const close = useCallback(() => dismiss(t.id), [t.id]);
  const isBack = index > 0 && !expanded;  const interactive = index === 0 || expanded;
  // Natural height of the content, independent of the collapsed clip.  useLayoutEffect(() => {    const el = contentRef.current;    if (!el) return;    const ro = new ResizeObserver(() => onHeight(t.id, el.offsetHeight));    ro.observe(el);    onHeight(t.id, el.offsetHeight);    return () => ro.disconnect();  }, [onHeight, t.id]);
  // Auto-dismiss timer. Restarts when the toast is updated (e.g. a promise  // settles) and holds while the stack is expanded or being swiped.  useEffect(() => {    remaining.current = t.duration;    progress.set(1);  }, [t.version, t.duration, progress]);
  useEffect(() => {    if (t.duration <= 0) return;    let raf = 0;    let last = performance.now();    const loop = (now: number) => {      const dt = now - last;      last = now;      if (!paused && !dragging) {        remaining.current -= dt;        progress.set(Math.max(0, remaining.current / t.duration));        if (remaining.current <= 0) return close();      }      raf = requestAnimationFrame(loop);    };    raf = requestAnimationFrame(loop);    return () => cancelAnimationFrame(raf);  }, [paused, dragging, t.duration, t.version, close, progress]);
  /* ------------------------------- swipe -------------------------------- */
  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {    if (!interactive || e.button !== 0 || (e.target as Element).closest("button")) return;    gesture.current = {      x: e.clientX,      y: e.clientY,      axis: null,      samples: [{ t: performance.now(), x: e.clientX, y: e.clientY }],    };  };
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {    const g = gesture.current;    if (!g) return;    const dx = e.clientX - g.x;    const dy = e.clientY - g.y;    if (!g.axis) {      if (Math.hypot(dx, dy) < 6) return;      g.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";      try {        e.currentTarget.setPointerCapture(e.pointerId);      } catch {        /* Pointer already released. */      }      setDragging(true);    }    g.samples.push({ t: performance.now(), x: e.clientX, y: e.clientY });    if (g.samples.length > 5) g.samples.shift();    if (g.axis === "x") sx.set(dx);    // Toward the screen edge moves freely; away from it resists.    else sy.set(dy * edge > 0 ? dy : dy * 0.18);  };
  const onPointerUp = () => {    const g = gesture.current;    gesture.current = null;    if (!g?.axis) return;    setDragging(false);    const a = g.samples[0];    const b = g.samples[g.samples.length - 1];    const dt = Math.max(16, b.t - a.t);    const value = g.axis === "x" ? sx : sy;    const v = ((g.axis === "x" ? b.x - a.x : b.y - a.y) / dt) * 1000;    const d = value.get();    const out =      g.axis === "x"        ? Math.abs(d) > 70 || Math.abs(v) > 450        : d * edge > 40 || v * edge > 450;    if (out) {      const dir = g.axis === "x" ? Math.sign(d || v) : edge;      animate(value, dir * 420, { type: "spring", bounce: 0, duration: 0.35, velocity: v });      close();    } else {      animate(value, 0, { type: "spring", bounce: 0.2, duration: 0.4, velocity: v });    }  };
  /* ------------------------------- render ------------------------------- */
  const icon = t.icon ?? (t.tone && t.tone !== "default" ? TONE_ICON[t.tone] : null);  const enterY = reduce ? 0 : edge * 28;
  return (    <motion.li      data-toast=""      tabIndex={0}      aria-roledescription="notification"      onKeyDown={(e) => {        if (e.key === "Escape") {          e.stopPropagation();          close();        }      }}      initial={{        y: enterY,        opacity: 0,        scale: reduce ? 1 : 0.96,        filter: reduce ? "blur(0px)" : "blur(6px)",      }}      animate={{        y: -edge * offset,        opacity: index >= 3 && !expanded ? 0 : 1,        scale: isBack && !reduce ? 1 - Math.min(index, 3) * 0.05 : 1,        filter: "blur(0px)",        height: isBack ? frontH : "auto",      }}      exit={{        opacity: 0,        scale: reduce ? 1 : 0.94,        filter: reduce ? "blur(0px)" : "blur(6px)",        transition: { duration: 0.22, ease: [0.23, 1, 0.32, 1] },      }}      transition={reduce ? { duration: 0.15 } : SPRING}      style={{        zIndex: total - index,        transformOrigin: edge === 1 ? "50% 0%" : "50% 100%",      }}      className={cn(        "absolute inset-x-0 overflow-hidden rounded-[16px] outline-none",        edge === 1 ? "bottom-0" : "top-0",        "bg-(--color-paper)/92 backdrop-blur-xl backdrop-saturate-150",        "shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.05),0_12px_32px_-8px_rgba(0,0,0,0.18)]",        "dark:shadow-[inset_0_0_0_1px_var(--color-rule),0_12px_32px_-8px_rgba(0,0,0,0.6)]",        "focus-visible:ring-2 focus-visible:ring-(--color-focus)",        !interactive && "pointer-events-none",      )}    >      <motion.div        ref={contentRef}        style={{ x: sx, y: sy, opacity: fade }}        onPointerDown={onPointerDown}        onPointerMove={onPointerMove}        onPointerUp={onPointerUp}        onPointerCancel={onPointerUp}        className={cn(          "group/toast relative flex touch-none items-start gap-3 py-3 pr-2.5 pl-3.5 select-none",          dragging ? "cursor-grabbing" : "cursor-default",        )}      >        {icon ? (          <motion.span            animate={{ opacity: isBack ? 0 : 1 }}            className="relative mt-px flex size-[18px] shrink-0 items-center justify-center [&_svg]:size-full [&_svg]:stroke-[1.9]"          >            <AnimatePresence initial={false} mode="popLayout">              <motion.span                key={t.tone ?? "custom"}                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, filter: "blur(4px)" }}                animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, filter: "blur(4px)" }}                transition={{ type: "spring", bounce: 0.35, duration: 0.45 }}                className="flex size-full"                aria-hidden              >                {icon}              </motion.span>            </AnimatePresence>          </motion.span>        ) : null}
        <motion.div          key={t.version}          initial={t.version === 0 || reduce ? false : { opacity: 0, y: 4, filter: "blur(3px)" }}          animate={{ opacity: isBack ? 0 : 1, y: 0, filter: "blur(0px)" }}          transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}          className="min-w-0 flex-1 pt-px"        >          <div className="text-[13.5px] leading-snug font-medium text-(--color-ink)">{t.title}</div>          {t.description ? (            <div className="mt-0.5 text-[12.5px] leading-relaxed text-(--color-neutral)">              {t.description}            </div>          ) : null}        </motion.div>
        {t.action ? (          <motion.button            type="button"            animate={{ opacity: isBack ? 0 : 1 }}            onClick={() => {              t.action?.onClick();              close();            }}            className={cn(              "mt-px h-7 shrink-0 rounded-full bg-(--color-ink) px-3 text-[12px] font-medium text-(--color-paper)",              "transition-transform duration-150 outline-none active:scale-[0.96]",              "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",            )}          >            {t.action.label}          </motion.button>        ) : null}
        <motion.button          type="button"          aria-label="Dismiss notification"          animate={{ opacity: isBack ? 0 : 1 }}          onClick={close}          className={cn(            "relative -my-0.5 flex size-8 shrink-0 items-center justify-center rounded-full outline-none",            "text-(--color-neutral) transition-[color,background-color,opacity,transform] duration-150",            "hover:bg-(--color-paper-2) hover:text-(--color-ink) active:scale-[0.92]",            "focus-visible:ring-2 focus-visible:ring-(--color-focus)",            "[@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/toast:opacity-100 [@media(hover:hover)]:focus-visible:opacity-100",          )}        >          <X className="size-3.5" strokeWidth={2} aria-hidden />        </motion.button>
        {t.duration > 0 ? (          <motion.span            aria-hidden            animate={{ opacity: isBack ? 0 : 1 }}            style={{ scaleX: progress }}            className={cn(              "pointer-events-none absolute inset-x-3.5 bottom-0 h-[2px] origin-left rounded-full",              TONE_BAR[t.tone ?? "default"],            )}          />        ) : null}      </motion.div>    </motion.li>  );}
/** @deprecated Mount `<Toaster />` once and call `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) => upsert(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<Toaster position="bottom-center" />
// Anywhere, no hook requiredtoast.success("Invite sent", "Mira can now open the workspace.");toast.error("Deploy failed", "Build #1842 exited with code 1.");
toast({  title: "Moved to trash",  action: { label: "Undo", onClick: restore },});
toast.promise(saveDraft(), {  loading: "Saving…",  success: "Draft saved",  error: (e) => `Couldn't save: ${String(e)}`,});

Props

PropTypeRequiredDefaultDescription
limitnumberNo3Most toasts kept at once. The oldest leaves when a new one arrives.
position"bottom-center" | "bottom-right" | "top-center" | "top-right"No"bottom-center"Viewport placement. Toasts enter from, and swipe out toward, that edge.
toast(input)(input: ToastInput) => stringNo—Show a toast and return its id. Input: title, description?, tone?, duration? (0 = sticky), action?, icon?, id? (reuse to update in place).
toast.success / error / warning / message / loading(title, description?) => stringNo—Tone shortcuts. Errors stay 6s, loading stays until updated, everything else 4s.
toast.promise(promise, { loading, success, error }) => stringNo—One toast that goes from loading to success or error. success and error can be functions of the result.
toast.update / toast.dismiss(id, input) => string · (id?) => voidNo—Change a toast in place (its timer restarts), or dismiss one or all.

Best Practices

  1. Mount one <Toaster /> per app; toast() works from event handlers, stores and async code.
  2. Keep titles to one line and put detail in description; the collapsed deck uses the front toast's height.
  3. Use an action for reversible operations instead of a confirm dialog.
  4. Use toast.promise rather than separate loading and result toasts, so the user follows one item.
  5. Timers pause while the deck is hovered or focused and while the tab is hidden; reduced motion swaps the blur and scale for a plain fade.