Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion, type PanInfo } from "motion/react";
import { CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";

/* ------------------------------------------------------------------ */
/* Store — a tiny external store so `toast()` works from anywhere.     */
/* ------------------------------------------------------------------ */

export type ToastType = "default" | "success" | "error" | "info" | "loading";

export interface ToastAction {
  label: string;
  onClick: () => void;
}

export interface ToastOptions {
  id?: string;
  description?: React.ReactNode;
  /** ms before auto-dismiss. `Infinity` keeps it open. Falls back to the Toaster's `duration`. */
  duration?: number;
  action?: ToastAction;
}

export interface ToastData extends ToastOptions {
  id: string;
  type: ToastType;
  title: React.ReactNode;
  /** Bumped whenever the toast is updated, so timers restart. */
  version: number;
}

type Listener = () => void;
let toasts: ToastData[] = [];
const listeners = new Set<Listener>();
let seq = 0;
const EMPTY: ToastData[] = [];

function emit() {
  listeners.forEach((l) => l());
}

function upsert(type: ToastType, title: React.ReactNode, opts: ToastOptions = {}) {
  const id = opts.id ?? `t${++seq}`;
  const existing = toasts.find((t) => t.id === id);
  if (existing) {
    toasts = toasts.map((t) => (t.id === id ? { ...t, ...opts, id, type, title, version: t.version + 1 } : t));
  } else {
    toasts = [...toasts, { ...opts, id, type, title, version: 0 }];
  }
  emit();
  return id;
}

function dismiss(id?: string) {
  toasts = id ? toasts.filter((t) => t.id !== id) : [];
  emit();
}

type PromiseMessages<T> = {
  loading: React.ReactNode;
  success: React.ReactNode | ((value: T) => React.ReactNode);
  error?: React.ReactNode | ((error: unknown) => React.ReactNode);
  description?: React.ReactNode;
};

function promiseToast<T>(promise: Promise<T> | (() => Promise<T>), msgs: PromiseMessages<T>, opts: ToastOptions = {}) {
  const id = upsert("loading", msgs.loading, { ...opts, duration: Infinity });
  const p = typeof promise === "function" ? promise() : promise;
  p.then(
    (v) => upsert("success", typeof msgs.success === "function" ? (msgs.success as (value: T) => React.ReactNode)(v) : msgs.success, { ...opts, id, duration: opts.duration }),
    (e) =>
      upsert("error", typeof msgs.error === "function" ? (msgs.error as (error: unknown) => React.ReactNode)(e) : (msgs.error ?? "Something went wrong"), {
        ...opts,
        id,
        duration: opts.duration,
      }),
  );
  return p;
}

/** Imperative API: `toast("Saved")`, `toast.success(...)`, `toast.promise(fetch(...), {...})`. */
export const toast = Object.assign((title: React.ReactNode, opts?: ToastOptions) => upsert("default", title, opts), {
  success: (title: React.ReactNode, opts?: ToastOptions) => upsert("success", title, opts),
  error: (title: React.ReactNode, opts?: ToastOptions) => upsert("error", title, opts),
  info: (title: React.ReactNode, opts?: ToastOptions) => upsert("info", title, opts),
  loading: (title: React.ReactNode, opts?: ToastOptions) => upsert("loading", title, { duration: Infinity, ...opts }),
  promise: promiseToast,
  dismiss,
});

const subscribe = (l: Listener) => {
  listeners.add(l);
  return () => listeners.delete(l);
};

export function useToast() {
  const list = React.useSyncExternalStore(subscribe, () => toasts, () => EMPTY);
  return { toasts: list, toast, dismiss };
}

/* ------------------------------------------------------------------ */
/* Toaster                                                             */
/* ------------------------------------------------------------------ */

export type ToasterPosition = "bottom-right" | "bottom-center" | "bottom-left" | "top-right" | "top-center" | "top-left";

export interface ToasterProps {
  position?: ToasterPosition;
  /** Default auto-dismiss time in ms. */
  duration?: number;
  /** Toasts visible in the collapsed stack. */
  visibleToasts?: number;
  /** Always show the stack expanded. */
  expand?: boolean;
  /** Gap between expanded toasts (px). */
  gap?: number;
  className?: string;
}

const PEEK = 14;

const positions: Record<ToasterPosition, string> = {
  "bottom-right": "bottom-4 right-4",
  "bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
  "bottom-left": "bottom-4 left-4",
  "top-right": "top-4 right-4",
  "top-center": "top-4 left-1/2 -translate-x-1/2",
  "top-left": "top-4 left-4",
};

export function Toaster({
  position = "bottom-right",
  duration = 4000,
  visibleToasts = 3,
  expand = false,
  gap = 10,
  className,
}: ToasterProps) {
  const { toasts: list } = useToast();
  const [hovered, setHovered] = React.useState(false);
  const [focused, setFocused] = React.useState(false);
  const [heights, setHeights] = React.useState<Record<string, number>>({});
  const bottom = position.startsWith("bottom");
  const interacting = (hovered || focused) && list.length > 0;
  const expanded = expand || interacting;
  const ordered = React.useMemo(() => [...list].reverse(), [list]);

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

  const frontH = ordered[0] ? (heights[ordered[0].id] ?? 64) : 0;
  const shown = ordered.slice(0, expanded ? ordered.length : visibleToasts);
  const offsets: number[] = [];
  let acc = 0;
  ordered.forEach((t, i) => {
    offsets.push(expanded ? acc : Math.min(i, visibleToasts - 1) * PEEK);
    acc += (heights[t.id] ?? 64) + gap;
  });
  const stackHeight = expanded ? Math.max(0, acc - gap) : frontH + (Math.min(shown.length, visibleToasts) - 1) * PEEK;

  return (
    <section
      aria-label="Notifications"
      className={cn(
        "fixed z-[100] w-[min(22.5rem,calc(100vw-2rem))] max-sm:left-4 max-sm:right-4 max-sm:w-auto max-sm:translate-x-0",
        positions[position],
        className,
      )}
    >
      <motion.ol
        onPointerEnter={() => setHovered(true)}
        onPointerLeave={() => setHovered(false)}
        onFocus={() => setFocused(true)}
        onBlur={(e) => {
          if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);
        }}
        animate={{ height: Math.max(stackHeight, 0) }}
        transition={{ type: "spring", stiffness: 400, damping: 36 }}
        className="relative"
      >
        <AnimatePresence initial={false}>
          {ordered.map((t, i) => (
            <ToastItem
              key={t.id}
              toast={t}
              index={i}
              offset={offsets[i]}
              expanded={expanded}
              hidden={!expanded && i >= visibleToasts}
              frontHeight={frontH}
              height={heights[t.id]}
              bottom={bottom}
              paused={interacting}
              duration={t.duration ?? duration}
              onHeight={onHeight}
            />
          ))}
        </AnimatePresence>
      </motion.ol>
    </section>
  );
}

const icons: Record<ToastType, React.ReactNode> = {
  default: null,
  success: <CheckCircle2 className="size-5 text-emerald-500" />,
  error: <XCircle className="size-5 text-red-500" />,
  info: <Info className="size-5 text-sky-500" />,
  loading: <Loader2 className="size-5 animate-spin text-muted-foreground" />,
};

interface ToastItemProps {
  toast: ToastData;
  index: number;
  offset: number;
  expanded: boolean;
  hidden: boolean;
  frontHeight: number;
  height?: number;
  bottom: boolean;
  paused: boolean;
  duration: number;
  onHeight: (id: string, h: number) => void;
}

function ToastItem({ toast: t, index, offset, expanded, hidden, frontHeight, height, bottom, paused, duration, onHeight }: ToastItemProps) {
  const reduce = useReducedMotion();
  const contentRef = React.useRef<HTMLDivElement>(null);
  const [swipe, setSwipe] = React.useState(0);
  const remaining = React.useRef(duration);
  const behind = !expanded && index > 0;

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

  // Restart the countdown whenever the toast is (re)published, e.g. loading → success.
  React.useEffect(() => {
    remaining.current = duration;
  }, [t.version, duration]);

  React.useEffect(() => {
    if (paused || t.type === "loading" || !Number.isFinite(remaining.current)) return;
    const started = Date.now();
    const timer = setTimeout(() => dismiss(t.id), Math.max(0, remaining.current));
    return () => {
      clearTimeout(timer);
      remaining.current -= Date.now() - started;
    };
  }, [paused, t.type, t.id, t.version]);

  const onDragEnd = (_: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) => {
    if (Math.abs(info.offset.x) > 90 || Math.abs(info.velocity.x) > 600) {
      setSwipe(Math.sign(info.offset.x || info.velocity.x));
      dismiss(t.id);
    }
  };

  const dir = bottom ? -1 : 1;
  const targetHeight = behind ? frontHeight : (height ?? "auto");

  return (
    <motion.li
      role={t.type === "error" ? "alert" : "status"}
      aria-live={t.type === "error" ? "assertive" : "polite"}
      aria-atomic
      initial={reduce ? { opacity: 0 } : { opacity: 0, y: dir * -48, scale: 0.96 }}
      animate={{
        opacity: hidden ? 0 : 1,
        y: dir * offset,
        scale: expanded || reduce ? 1 : 1 - Math.min(index, 3) * 0.05,
        height: targetHeight,
      }}
      exit={
        swipe
          ? { opacity: 0, x: swipe * 360, transition: { duration: 0.25 } }
          : { opacity: 0, scale: 0.94, y: dir * offset + dir * -16, transition: { duration: 0.2 } }
      }
      transition={{ type: "spring", stiffness: 380, damping: 34 }}
      drag={reduce ? false : "x"}
      dragSnapToOrigin
      dragElastic={0.5}
      onDragEnd={onDragEnd}
      style={{ zIndex: 50 - index, transformOrigin: bottom ? "bottom center" : "top center", pointerEvents: hidden ? "none" : undefined }}
      className={cn(
        "group absolute inset-x-0 cursor-grab touch-pan-y overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg shadow-black/5 active:cursor-grabbing dark:shadow-black/40",
        bottom ? "bottom-0" : "top-0",
      )}
    >
      <motion.div
        ref={contentRef}
        animate={{ opacity: behind ? 0 : 1 }}
        transition={{ duration: 0.15 }}
        className="flex items-start gap-3 p-4 pr-10"
      >
        {icons[t.type] && (
          <span className="relative mt-px grid size-5 shrink-0 place-items-center">
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={t.type}
                initial={{ scale: 0.4, opacity: 0, rotate: -30 }}
                animate={{ scale: 1, opacity: 1, rotate: 0 }}
                exit={{ scale: 0.4, opacity: 0 }}
                transition={{ type: "spring", stiffness: 500, damping: 25 }}
                className="grid place-items-center"
              >
                {icons[t.type]}
              </motion.span>
            </AnimatePresence>
          </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-sm leading-5 text-muted-foreground">{t.description}</p>}
          {t.action && (
            <button
              type="button"
              onClick={() => {
                t.action?.onClick();
                dismiss(t.id);
              }}
              className="mt-2 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground outline-none transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring"
            >
              {t.action.label}
            </button>
          )}
        </div>
      </motion.div>
      <button
        type="button"
        aria-label="Dismiss notification"
        onClick={() => dismiss(t.id)}
        tabIndex={behind ? -1 : 0}
        className="absolute right-2 top-2 rounded-md p-1.5 text-muted-foreground opacity-0 outline-none transition hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100 max-sm:opacity-100"
      >
        <X className="size-3.5" />
      </button>
    </motion.li>
  );
}

More in Overlays & Feedback

View all →