# Toast

Stacked toasts that fan out on hover, swipe away horizontally, and pause their timers while you read.

**Category:** Overlays  
**Exports:** `toast`, `Toaster`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/toast.json
```
The file lands at `components/ui/toast.tsx`; import from `@/components/ui/toast`.

## API — Toast
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `toast(title, options?)` | `(title: string, { description?, variant?: 'default' \| 'success' \| 'error', duration? }) => id` | — |  |
| `toast.dismiss(id)` | `(id: number) => void` | — |  |
| `<Toaster />` | `Mount once, near the root.` | — |  |

## Example — Toast

```tsx
import { toast } from "@/components/ui/toast";
import { Button } from "@/components/ui/button";

// <Toaster /> is mounted once at the app root.
export default function ToastDemo() {
  return (
    <div className="flex flex-wrap justify-center gap-3">
      <Button variant="outline" onClick={() => toast("Draft saved", { description: "Autosaved a few seconds ago." })}>
        Default
      </Button>
      <Button variant="outline" onClick={() => toast.success("Deployed to production", { description: "charcoal-ui is live." })}>
        Success
      </Button>
      <Button variant="outline" onClick={() => toast.error("Payment failed", { description: "Your card was declined. Try another card." })}>
        Error
      </Button>
    </div>
  );
}
```

## Source — components/ui/toast.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

type Variant = "default" | "success" | "error";
type ToastData = { id: number; title: string; description?: string; variant: Variant; duration: number };
type ToastOptions = { description?: string; variant?: Variant; duration?: number };

/* ------------------------------------------------------------------ store */

let toasts: ToastData[] = [];
let nextId = 1;
const listeners = new Set<() => void>();
const emit = () => listeners.forEach((l) => l());

export function toast(title: string, options: ToastOptions = {}) {
  const id = nextId++;
  toasts = [{ id, title, description: options.description, variant: options.variant ?? "default", duration: options.duration ?? 4000 }, ...toasts];
  emit();
  return id;
}
toast.success = (title: string, options: Omit<ToastOptions, "variant"> = {}) => toast(title, { ...options, variant: "success" });
toast.error = (title: string, options: Omit<ToastOptions, "variant"> = {}) => toast(title, { ...options, variant: "error" });
toast.dismiss = (id: number) => {
  toasts = toasts.filter((t) => t.id !== id);
  emit();
};

function useToasts() {
  return React.useSyncExternalStore(
    (l) => {
      listeners.add(l);
      return () => listeners.delete(l);
    },
    () => toasts,
    () => toasts,
  );
}

/* ---------------------------------------------------------------- toaster */

const GAP = 10;
const VISIBLE = 3;

export function Toaster({ className }: { className?: string }) {
  const list = useToasts();
  const reduce = useReducedMotion();
  const [expanded, setExpanded] = React.useState(false);
  const [heights, setHeights] = React.useState<Record<number, number>>({});

  React.useEffect(() => {
    if (list.length === 0) setExpanded(false);
  }, [list.length]);

  const setHeight = React.useCallback((id: number, h: number) => {
    setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));
  }, []);

  const frontHeight = list.length ? (heights[list[0].id] ?? 64) : 0;
  const totalHeight = list.reduce((sum, t, i) => sum + (heights[t.id] ?? 64) + (i ? GAP : 0), 0);

  return (
    <section
      aria-label="Notifications"
      className={cn(
        "fixed bottom-4 right-4 z-[60] w-[min(360px,calc(100vw-2rem))] max-sm:left-4 max-sm:right-4 max-sm:w-auto",
        className,
      )}
      // While expanded the region covers the whole stack, so the gaps between toasts keep it open.
      style={{ height: expanded ? totalHeight : frontHeight }}
      onPointerEnter={() => setExpanded(true)}
      onPointerLeave={() => setExpanded(false)}
      onFocus={() => setExpanded(true)}
      onBlur={(e) => !e.currentTarget.contains(e.relatedTarget) && setExpanded(false)}
    >
      <AnimatePresence initial={false}>
        {list.map((t, i) => {
          let offset = 0;
          for (let j = 0; j < i; j++) offset += (heights[list[j].id] ?? 64) + GAP;
          const y = expanded ? -offset : -i * 12;
          const scale = expanded ? 1 : 1 - i * 0.05;
          const hidden = i >= VISIBLE && !expanded;
          return (
            <ToastItem
              key={t.id}
              toast={t}
              index={i}
              y={y}
              scale={scale}
              hidden={hidden}
              collapsedHeight={!expanded && i > 0 ? frontHeight : undefined}
              paused={expanded}
              reduce={!!reduce}
              onHeight={setHeight}
            />
          );
        })}
      </AnimatePresence>
    </section>
  );
}

const icons: Record<Variant, React.ReactNode> = {
  default: <span className="mt-1.5 size-2 rounded-full bg-ember shadow-[0_0_10px_var(--color-ember)]" />,
  success: (
    <svg viewBox="0 0 24 24" className="size-[18px] text-success" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
      <circle cx="12" cy="12" r="9" />
      <path d="m8.5 12.5 2.5 2.5 4.5-5" />
    </svg>
  ),
  error: (
    <svg viewBox="0 0 24 24" className="size-[18px] text-destructive" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
      <circle cx="12" cy="12" r="9" />
      <path d="M12 7.5v5.5M12 16.5h.01" />
    </svg>
  ),
};

function ToastItem({
  toast: t,
  index,
  y,
  scale,
  hidden,
  collapsedHeight,
  paused,
  reduce,
  onHeight,
}: {
  toast: ToastData;
  index: number;
  y: number;
  scale: number;
  hidden: boolean;
  collapsedHeight?: number;
  paused: boolean;
  reduce: boolean;
  onHeight: (id: number, h: number) => void;
}) {
  const inner = React.useRef<HTMLDivElement>(null);
  const remaining = React.useRef(t.duration);
  const [exitX, setExitX] = React.useState(0);

  React.useLayoutEffect(() => {
    const el = inner.current;
    if (!el) return;
    const ro = new ResizeObserver(() => onHeight(t.id, el.offsetHeight));
    ro.observe(el);
    return () => ro.disconnect();
  }, [t.id, onHeight]);

  // Count down only while the stack is collapsed and the tab is visible.
  React.useEffect(() => {
    if (paused || t.duration === Infinity) return;
    const started = Date.now();
    const timer = window.setTimeout(() => toast.dismiss(t.id), remaining.current);
    return () => {
      window.clearTimeout(timer);
      remaining.current -= Date.now() - started;
    };
  }, [paused, t.id, t.duration]);

  return (
    <motion.div
      layout={false}
      role={t.variant === "error" ? "alert" : "status"}
      aria-atomic
      className="group absolute inset-x-0 bottom-0 origin-bottom touch-pan-y overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-[0_16px_40px_-12px_rgb(0_0_0/0.35)]"
      style={{ zIndex: 100 - index }}
      initial={{ opacity: 0, y: reduce ? 0 : 24, scale: reduce ? 1 : 0.96 }}
      animate={{ opacity: hidden ? 0 : 1, y, scale, height: collapsedHeight ?? "auto" }}
      exit={{ opacity: 0, x: exitX, scale: exitX ? 1 : 0.94, transition: { duration: 0.2 } }}
      transition={{ type: "spring", stiffness: 420, damping: 36 }}
      drag="x"
      dragSnapToOrigin
      dragElastic={0.4}
      onDragEnd={(_, info) => {
        if (Math.abs(info.offset.x) > 90 || Math.abs(info.velocity.x) > 600) {
          setExitX(info.offset.x > 0 ? 400 : -400);
          toast.dismiss(t.id);
        }
      }}
    >
      <div
        ref={inner}
        className={cn(
          "flex items-start gap-3 p-4 pr-10 transition-opacity duration-200",
          collapsedHeight !== undefined && "[&>*]:opacity-0",
        )}
      >
        <span className="flex size-[18px] shrink-0 items-center justify-center">{icons[t.variant]}</span>
        <div className="min-w-0 flex-1">
          <p className="text-sm font-medium leading-5">{t.title}</p>
          {t.description && <p className="mt-0.5 text-[13px] leading-5 text-muted-foreground">{t.description}</p>}
        </div>
        <button
          type="button"
          onClick={() => toast.dismiss(t.id)}
          aria-label="Dismiss notification"
          className="absolute right-2 top-2 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground opacity-100 outline-none transition hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100"
        >
          <svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden>
            <path d="M6 6l12 12M18 6 6 18" />
          </svg>
        </button>
      </div>
    </motion.div>
  );
}
```