Web InnoventixFreeCode

Promise Toast

Original · free

loading-to-result promise toast

byWeb InnoventixReact + Tailwind
toastpromisetoasts
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/toast-promise.json
toast-promise.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Status = "loading" | "success" | "error";
type Accent = "indigo" | "violet" | "sky";

type Copy = { title: string; description: string };

type TaskDef = {
  key: string;
  label: string;
  accent: Accent;
  loading: Copy;
  success: Copy;
  error: Copy;
};

type Toast = {
  id: number;
  status: Status;
  title: string;
  description: string;
  task: TaskDef;
};

type TimerState = {
  remaining: number;
  deadline: number;
  timeout: ReturnType<typeof setTimeout> | null;
};

const AUTO_DISMISS = 5000;

const TASKS: TaskDef[] = [
  {
    key: "save",
    label: "Save draft",
    accent: "indigo",
    loading: { title: "Saving your draft…", description: "Syncing the latest edits." },
    success: { title: "Draft saved", description: "Every change is backed up to the cloud." },
    error: { title: "Couldn't save draft", description: "The workspace didn't respond. Give it another go." },
  },
  {
    key: "deploy",
    label: "Deploy build",
    accent: "violet",
    loading: { title: "Deploying build…", description: "Rolling v2.4.1 out to production." },
    success: { title: "Deploy complete", description: "v2.4.1 is live for every region." },
    error: { title: "Deploy failed", description: "Build step 4 exited with code 1." },
  },
  {
    key: "invite",
    label: "Send invite",
    accent: "sky",
    loading: { title: "Sending invite…", description: "Reaching jordan@northwind.io." },
    success: { title: "Invite sent", description: "Jordan will get an email in a moment." },
    error: { title: "Invite not sent", description: "That address bounced back to us." },
  },
];

const ACCENTS: Record<Accent, string> = {
  indigo: "bg-indigo-600 hover:bg-indigo-500 focus-visible:ring-indigo-500",
  violet: "bg-violet-600 hover:bg-violet-500 focus-visible:ring-violet-500",
  sky: "bg-sky-600 hover:bg-sky-500 focus-visible:ring-sky-500",
};

const RING: Record<Status, string> = {
  loading: "bg-indigo-50 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300",
  success: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300",
  error: "bg-rose-50 text-rose-600 dark:bg-rose-500/15 dark:text-rose-300",
};

function TaskIcon({ k }: { k: string }) {
  if (k === "save") {
    return (
      <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
        <path d="M6 4h9l3 3v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z" strokeLinejoin="round" />
        <path d="M8 4v5h6V4M8 14h8v5H8z" strokeLinejoin="round" />
      </svg>
    );
  }
  if (k === "deploy") {
    return (
      <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
        <path d="M12 3c3 1.6 5 4.7 5 8.2L14 14H10L7 11.2C7 7.7 9 4.6 12 3Z" strokeLinejoin="round" />
        <circle cx="12" cy="9.2" r="1.5" />
        <path d="M9 15l-2 4 3.2-1.5M15 15l2 4-3.2-1.5" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    );
  }
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
      <path d="M21 3 3 11l7 3 3 7 8-18Z" strokeLinejoin="round" />
      <path d="M21 3 10 14" strokeLinecap="round" />
    </svg>
  );
}

function StatusIcon({ status }: { status: Status }) {
  if (status === "loading") {
    return (
      <svg viewBox="0 0 24 24" className="tp-spin h-5 w-5" fill="none" aria-hidden="true">
        <circle cx="12" cy="12" r="9" className="stroke-current opacity-20" strokeWidth="3" />
        <path d="M21 12a9 9 0 0 0-9-9" className="stroke-current" strokeWidth="3" strokeLinecap="round" />
      </svg>
    );
  }
  if (status === "success") {
    return (
      <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" aria-hidden="true">
        <path d="M20 6 9 17l-5-5" className="stroke-current" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    );
  }
  return (
    <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" aria-hidden="true">
      <path d="M18 6 6 18M6 6l12 12" className="stroke-current" strokeWidth="2.5" strokeLinecap="round" />
    </svg>
  );
}

export default function ToastPromise() {
  const reduce = useReducedMotion() ?? false;
  const [toasts, setToasts] = useState<Toast[]>([]);
  const [forceFail, setForceFail] = useState(false);
  const [pausedIds, setPausedIds] = useState<Set<number>>(() => new Set());

  const idRef = useRef(1);
  const forceFailRef = useRef(forceFail);
  const pausedIdsRef = useRef(pausedIds);
  const timers = useRef<Map<number, TimerState>>(new Map());

  useEffect(() => {
    forceFailRef.current = forceFail;
  }, [forceFail]);

  useEffect(() => {
    pausedIdsRef.current = pausedIds;
  }, [pausedIds]);

  const removeToast = useCallback((id: number) => {
    const t = timers.current.get(id);
    if (t?.timeout) clearTimeout(t.timeout);
    timers.current.delete(id);
    setToasts((prev) => prev.filter((x) => x.id !== id));
    setPausedIds((prev) => {
      if (!prev.has(id)) return prev;
      const next = new Set(prev);
      next.delete(id);
      return next;
    });
  }, []);

  const startTimer = useCallback(
    (id: number, duration: number) => {
      const existing = timers.current.get(id);
      if (existing?.timeout) clearTimeout(existing.timeout);
      const timeout = setTimeout(() => removeToast(id), duration);
      timers.current.set(id, { remaining: duration, deadline: Date.now() + duration, timeout });
    },
    [removeToast],
  );

  const handlePause = useCallback((id: number) => {
    const t = timers.current.get(id);
    if (t?.timeout) {
      clearTimeout(t.timeout);
      t.remaining = Math.max(0, t.deadline - Date.now());
      t.timeout = null;
    }
    setPausedIds((prev) => {
      if (prev.has(id)) return prev;
      const next = new Set(prev);
      next.add(id);
      return next;
    });
  }, []);

  const handleResume = useCallback(
    (id: number) => {
      const t = timers.current.get(id);
      if (t && !t.timeout) {
        t.deadline = Date.now() + t.remaining;
        t.timeout = setTimeout(() => removeToast(id), t.remaining);
      }
      setPausedIds((prev) => {
        if (!prev.has(id)) return prev;
        const next = new Set(prev);
        next.delete(id);
        return next;
      });
    },
    [removeToast],
  );

  const runTask = useCallback(
    (task: TaskDef, existingId?: number) => {
      const id = existingId ?? idRef.current++;

      const existing = timers.current.get(id);
      if (existing?.timeout) clearTimeout(existing.timeout);
      timers.current.delete(id);
      setPausedIds((prev) => {
        if (!prev.has(id)) return prev;
        const next = new Set(prev);
        next.delete(id);
        return next;
      });

      const loading: Toast = {
        id,
        status: "loading",
        title: task.loading.title,
        description: task.loading.description,
        task,
      };

      setToasts((prev) => {
        if (existingId !== undefined && prev.some((t) => t.id === id)) {
          return prev.map((t) => (t.id === id ? loading : t));
        }
        return [...prev, loading];
      });

      const willFail = forceFailRef.current;
      const delay = 1500 + Math.round(Math.random() * 900);

      new Promise<void>((resolve, reject) => {
        setTimeout(() => (willFail ? reject(new Error("failed")) : resolve()), delay);
      })
        .then(() => {
          setToasts((prev) =>
            prev.map((t) =>
              t.id === id
                ? { ...t, status: "success", title: task.success.title, description: task.success.description }
                : t,
            ),
          );
          if (pausedIdsRef.current.has(id)) {
            timers.current.set(id, {
              remaining: AUTO_DISMISS,
              deadline: Date.now() + AUTO_DISMISS,
              timeout: null,
            });
          } else {
            startTimer(id, AUTO_DISMISS);
          }
        })
        .catch(() => {
          setToasts((prev) =>
            prev.map((t) =>
              t.id === id
                ? { ...t, status: "error", title: task.error.title, description: task.error.description }
                : t,
            ),
          );
        });
    },
    [startTimer],
  );

  const dismissAll = useCallback(() => {
    timers.current.forEach((t) => {
      if (t.timeout) clearTimeout(t.timeout);
    });
    timers.current.clear();
    setToasts([]);
    setPausedIds(new Set());
  }, []);

  useEffect(() => {
    const map = timers.current;
    return () => {
      map.forEach((t) => {
        if (t.timeout) clearTimeout(t.timeout);
      });
      map.clear();
    };
  }, []);

  const enterProps = reduce
    ? {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0 },
        transition: { duration: 0.14 },
      }
    : {
        initial: { opacity: 0, y: 26, scale: 0.95 },
        animate: { opacity: 1, y: 0, scale: 1 },
        exit: { opacity: 0, scale: 0.9, transition: { duration: 0.18 } },
        transition: { type: "spring" as const, stiffness: 420, damping: 34, mass: 0.8 },
      };

  const knobTransition = reduce
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 500, damping: 34 };

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-4 py-20 dark:from-slate-950 dark:to-slate-900 sm:py-28">
      <style>{`
        @keyframes tp-spin { to { transform: rotate(360deg); } }
        @keyframes tp-bar { from { transform: scaleX(1); } to { transform: scaleX(0); } }
        .tp-spin { animation: tp-spin 0.7s linear infinite; }
        .tp-bar { transform-origin: left center; animation-name: tp-bar; animation-timing-function: linear; animation-fill-mode: forwards; }
        @media (prefers-reduced-motion: reduce) {
          .tp-spin { animation: none; }
          .tp-bar { animation: none; transform: scaleX(1); }
        }
      `}</style>

      <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
        <div className="absolute -left-24 top-1/4 h-72 w-72 rounded-full bg-indigo-300/20 blur-3xl dark:bg-indigo-600/10" />
        <div className="absolute -right-16 bottom-1/4 h-72 w-72 rounded-full bg-violet-300/20 blur-3xl dark:bg-violet-600/10" />
      </div>

      <div className="relative z-10 mx-auto max-w-5xl">
        <div className="mx-auto max-w-2xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium text-slate-600 backdrop-blur dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-300">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Async feedback
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
            Promise toasts
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Fire an action and watch a single toast travel from loading to its result. Hover or focus a
            toast to pause its auto-dismiss, or force a failure and retry it in place.
          </p>
        </div>

        <div className="mx-auto mt-10 max-w-2xl rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 sm:p-8">
          <div className="flex flex-col gap-3 sm:flex-row">
            {TASKS.map((task) => (
              <button
                key={task.key}
                type="button"
                onClick={() => runTask(task)}
                className={`inline-flex flex-1 items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-semibold text-white shadow-sm transition active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${ACCENTS[task.accent]}`}
              >
                <TaskIcon k={task.key} />
                {task.label}
              </button>
            ))}
          </div>

          <div className="mt-6 flex flex-col gap-4 border-t border-slate-200 pt-5 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
            <div className="flex items-center gap-3">
              <button
                type="button"
                role="switch"
                aria-checked={forceFail}
                aria-labelledby="tp-forcefail-label"
                onClick={() => setForceFail((v) => !v)}
                className={`inline-flex h-6 w-11 shrink-0 items-center rounded-full px-0.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                  forceFail ? "bg-rose-500" : "bg-slate-300 dark:bg-slate-700"
                }`}
              >
                <motion.span
                  className="inline-block h-5 w-5 rounded-full bg-white shadow"
                  animate={{ x: forceFail ? 20 : 0 }}
                  transition={knobTransition}
                />
              </button>
              <span id="tp-forcefail-label" className="text-sm font-medium text-slate-700 dark:text-slate-300">
                Force the next action to fail
              </span>
            </div>

            <button
              type="button"
              onClick={dismissAll}
              disabled={toasts.length === 0}
              className="inline-flex items-center gap-2 self-start rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition disabled:cursor-not-allowed disabled:opacity-50 enabled:hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:enabled:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900 sm:self-auto"
            >
              Clear all
              {toasts.length > 0 && (
                <span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-slate-900 px-1.5 text-xs font-semibold text-white dark:bg-white dark:text-slate-900">
                  {toasts.length}
                </span>
              )}
            </button>
          </div>
        </div>
      </div>

      <ul
        aria-label="Notifications"
        className="pointer-events-none absolute inset-x-4 bottom-4 z-30 flex flex-col gap-3 sm:inset-x-auto sm:bottom-6 sm:right-6 sm:w-[22rem]"
      >
        <AnimatePresence initial={false}>
          {toasts.map((t) => (
            <motion.li
              key={t.id}
              layout
              {...enterProps}
              role={t.status === "error" ? "alert" : "status"}
              aria-atomic="true"
              onMouseEnter={() => handlePause(t.id)}
              onMouseLeave={() => handleResume(t.id)}
              onFocus={() => handlePause(t.id)}
              onBlur={() => handleResume(t.id)}
              className="pointer-events-auto relative overflow-hidden rounded-2xl border border-slate-200 bg-white/95 shadow-lg shadow-slate-900/5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/95 dark:shadow-black/40"
            >
              <div className="flex items-start gap-3 p-4">
                <span
                  className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${RING[t.status]}`}
                >
                  <StatusIcon status={t.status} />
                </span>

                <div className="min-w-0 flex-1">
                  <p className="text-sm font-semibold text-slate-900 dark:text-white">{t.title}</p>
                  <p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">{t.description}</p>

                  {t.status === "error" && (
                    <div className="mt-3 flex items-center gap-2">
                      <button
                        type="button"
                        onClick={() => runTask(t.task, t.id)}
                        className="inline-flex items-center gap-1.5 rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
                      >
                        <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" aria-hidden="true">
                          <path
                            d="M3 12a9 9 0 1 0 3-6.7"
                            className="stroke-current"
                            strokeWidth="2"
                            strokeLinecap="round"
                          />
                          <path
                            d="M3 4v5h5"
                            className="stroke-current"
                            strokeWidth="2"
                            strokeLinecap="round"
                            strokeLinejoin="round"
                          />
                        </svg>
                        Retry
                      </button>
                      <button
                        type="button"
                        onClick={() => removeToast(t.id)}
                        className="rounded-lg px-3 py-1.5 text-xs font-medium text-slate-500 transition hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
                      >
                        Dismiss
                      </button>
                    </div>
                  )}
                </div>

                <button
                  type="button"
                  onClick={() => removeToast(t.id)}
                  aria-label={`Dismiss ${t.title}`}
                  className="-mr-1 -mt-1 shrink-0 rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:hover:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
                >
                  <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden="true">
                    <path
                      d="M18 6 6 18M6 6l12 12"
                      className="stroke-current"
                      strokeWidth="2"
                      strokeLinecap="round"
                    />
                  </svg>
                </button>
              </div>

              {t.status === "success" && (
                <div className="absolute inset-x-0 bottom-0 h-1 bg-slate-100 dark:bg-slate-800">
                  <div
                    className="tp-bar h-full bg-emerald-500 dark:bg-emerald-400"
                    style={{
                      animationDuration: `${AUTO_DISMISS}ms`,
                      animationPlayState: pausedIds.has(t.id) ? "paused" : "running",
                    }}
                  />
                </div>
              )}
            </motion.li>
          ))}
        </AnimatePresence>
      </ul>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →