Web InnoventixFreeCode

Action Toast

Original · free

toast with an undo action

byWeb InnoventixReact + Tailwind
toastactiontoasts
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-action.json
toast-action.tsx
"use client";

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

type InboxItem = {
  id: string;
  title: string;
  meta: string;
  unread: boolean;
};

type UndoToast = {
  id: string;
  item: InboxItem;
  index: number;
  duration: number;
  remaining: number;
  hover: boolean;
  focus: boolean;
};

const DURATION = 6000;
const MAX_TOASTS = 3;

const INITIAL_ITEMS: InboxItem[] = [
  { id: "m-roadmap", title: "Q3 roadmap needs your sign-off", meta: "Priya Nair · 9:24 AM", unread: true },
  { id: "m-invoice", title: "Invoice #4021 is ready to approve", meta: "Billing · Yesterday", unread: true },
  { id: "m-figma", title: "3 new comments on “Onboarding revamp”", meta: "Figma · Yesterday", unread: false },
  { id: "m-standup", title: "Standup notes — July 15", meta: "Marcus Bell · Tue", unread: false },
  { id: "m-security", title: "New sign-in from Lisbon", meta: "Accounts · Tue", unread: false },
  { id: "m-lunch", title: "Lunch & learn: shipping faster", meta: "People Ops · Mon", unread: false },
];

function ArchiveIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <rect x="3" y="4" width="18" height="4.5" rx="1.2" />
      <path d="M5 8.5V18a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5" />
      <path d="M10 12.5h4" />
    </svg>
  );
}

function UndoIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M4 9h11.5a4.5 4.5 0 1 1 0 9H9" />
      <path d="M7.5 4.5 3.5 9l4 4.5" />
    </svg>
  );
}

function CloseIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M6 6l12 12M18 6 6 18" />
    </svg>
  );
}

function InboxIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M4 13h4l1.8 3h4.4L16 13h4" />
      <path d="M4 13 6.4 5.4A2 2 0 0 1 8.3 4h7.4a2 2 0 0 1 1.9 1.4L20 13v4.5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" />
    </svg>
  );
}

function RefreshIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M20 11a8 8 0 1 0-1.7 6.1" />
      <path d="M20 4v5h-5" />
    </svg>
  );
}

export default function ToastAction() {
  const reduce = useReducedMotion();

  const [items, setItems] = useState<InboxItem[]>(INITIAL_ITEMS);
  const [toasts, setToasts] = useState<UndoToast[]>([]);

  const itemsRef = useRef<InboxItem[]>(items);
  const toastsRef = useRef<UndoToast[]>(toasts);
  const seq = useRef(0);

  useEffect(() => {
    itemsRef.current = items;
  }, [items]);
  useEffect(() => {
    toastsRef.current = toasts;
  }, [toasts]);

  const hasToasts = toasts.length > 0;

  useEffect(() => {
    if (!hasToasts) return;
    let last = performance.now();
    const id = window.setInterval(() => {
      const now = performance.now();
      const delta = now - last;
      last = now;
      setToasts((prev) => {
        let changed = false;
        const next: UndoToast[] = [];
        for (const t of prev) {
          if (t.hover || t.focus) {
            next.push(t);
            continue;
          }
          const remaining = t.remaining - delta;
          if (remaining <= 0) {
            changed = true;
            continue;
          }
          changed = true;
          next.push({ ...t, remaining });
        }
        return changed ? next : prev;
      });
    }, 90);
    return () => window.clearInterval(id);
  }, [hasToasts]);

  const archive = useCallback((id: string) => {
    const current = itemsRef.current;
    const index = current.findIndex((x) => x.id === id);
    if (index === -1) return;
    const item = current[index];
    setItems((prev) => prev.filter((x) => x.id !== id));
    seq.current += 1;
    const toastId = `ta-toast-${item.id}-${seq.current}`;
    setToasts((prev) =>
      [
        { id: toastId, item, index, duration: DURATION, remaining: DURATION, hover: false, focus: false },
        ...prev,
      ].slice(0, MAX_TOASTS),
    );
  }, []);

  const undo = useCallback((toastId: string) => {
    const t = toastsRef.current.find((x) => x.id === toastId);
    if (!t) return;
    setItems((prev) => {
      const arr = prev.slice();
      arr.splice(Math.min(t.index, arr.length), 0, t.item);
      return arr;
    });
    setToasts((prev) => prev.filter((x) => x.id !== toastId));
  }, []);

  const dismiss = useCallback((toastId: string) => {
    setToasts((prev) => prev.filter((x) => x.id !== toastId));
  }, []);

  const setPause = useCallback(
    (toastId: string, key: "hover" | "focus", value: boolean) => {
      setToasts((prev) => prev.map((t) => (t.id === toastId ? { ...t, [key]: value } : t)));
    },
    [],
  );

  const reset = useCallback(() => {
    setToasts([]);
    setItems(INITIAL_ITEMS);
  }, []);

  const handleBlur = useCallback(
    (toastId: string, e: FocusEvent<HTMLLIElement>) => {
      if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
        setPause(toastId, "focus", false);
      }
    },
    [setPause],
  );

  const archivedCount = INITIAL_ITEMS.length - items.length;
  const canReset = items.length < INITIAL_ITEMS.length || toasts.length > 0;

  const itemSpring: Transition = reduce
    ? { duration: 0.15 }
    : { type: "spring", stiffness: 520, damping: 42 };
  const toastSpring: Transition = reduce
    ? { duration: 0.16, ease: "easeOut" }
    : { type: "spring", stiffness: 420, damping: 34, mass: 0.9 };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 [background-image:radial-gradient(circle_at_18%_12%,rgba(99,102,241,0.10),transparent_45%),radial-gradient(circle_at_88%_82%,rgba(16,185,129,0.08),transparent_50%)]"
      />

      <div className="relative mx-auto w-full max-w-2xl">
        <header className="mb-8">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium tracking-wide text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Reversible actions
          </span>
          <h2 className="mt-4 text-2xl font-semibold tracking-tight sm:text-3xl">
            Archive without the fear
          </h2>
          <p className="mt-2 max-w-lg text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            Every archive fires a toast with a six-second window to undo. Hover or focus a
            toast to pause its timer — mistakes should never be permanent by accident.
          </p>
        </header>

        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="flex items-center justify-between gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800">
            <div className="flex items-center gap-2">
              <InboxIcon className="h-5 w-5 text-slate-400 dark:text-slate-500" />
              <span className="text-sm font-medium">Inbox</span>
              <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
                {items.length}
              </span>
            </div>
            <button
              type="button"
              onClick={reset}
              disabled={!canReset}
              className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:pointer-events-none disabled:opacity-40 dark:hover:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
            >
              <RefreshIcon className="h-3.5 w-3.5" />
              Reset{archivedCount > 0 ? ` (${archivedCount})` : ""}
            </button>
          </div>

          <ul className="divide-y divide-slate-100 dark:divide-slate-800/70">
            <AnimatePresence initial={false} mode="popLayout">
              {items.map((item) => (
                <motion.li
                  key={item.id}
                  layout={!reduce}
                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, x: -32 }}
                  transition={itemSpring}
                  className="group flex items-center gap-3 px-4 py-3"
                >
                  <span
                    aria-hidden="true"
                    className={`h-2 w-2 shrink-0 rounded-full ${
                      item.unread ? "bg-indigo-500" : "bg-transparent ring-1 ring-inset ring-slate-300 dark:ring-slate-600"
                    }`}
                  />
                  <div className="min-w-0 flex-1">
                    <p
                      className={`truncate text-sm ${
                        item.unread
                          ? "font-semibold text-slate-900 dark:text-slate-50"
                          : "font-medium text-slate-700 dark:text-slate-200"
                      }`}
                    >
                      {item.title}
                    </p>
                    <p className="truncate text-xs text-slate-500 dark:text-slate-400">{item.meta}</p>
                  </div>
                  <button
                    type="button"
                    onClick={() => archive(item.id)}
                    aria-label={`Archive: ${item.title}`}
                    className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-50 dark:focus-visible:ring-offset-slate-900"
                  >
                    <ArchiveIcon className="h-4 w-4" />
                    <span className="hidden sm:inline">Archive</span>
                  </button>
                </motion.li>
              ))}
            </AnimatePresence>

            {items.length === 0 && (
              <li className="flex flex-col items-center gap-3 px-6 py-14 text-center">
                <span className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300">
                  <InboxIcon className="h-6 w-6" />
                </span>
                <div>
                  <p className="text-sm font-semibold">Inbox zero</p>
                  <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
                    Everything is archived. Undo any toast that is still on screen, or reset the demo.
                  </p>
                </div>
                <button
                  type="button"
                  onClick={reset}
                  className="mt-1 inline-flex items-center gap-1.5 rounded-lg bg-slate-900 px-3.5 py-2 text-xs font-semibold text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-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"
                >
                  <RefreshIcon className="h-3.5 w-3.5" />
                  Restore inbox
                </button>
              </li>
            )}
          </ul>
        </div>

        <p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
          Tip: keyboard-focus a toast to hold the countdown while you decide.
        </p>
      </div>

      {/* Toast viewport */}
      <ol
        aria-live="polite"
        aria-label="Recent actions"
        className="pointer-events-none fixed inset-x-0 bottom-0 z-50 mx-auto flex w-full max-w-sm flex-col items-stretch gap-2 px-4 pb-4 sm:inset-x-auto sm:right-6 sm:bottom-6 sm:mx-0 sm:px-0"
      >
        <AnimatePresence initial mode="popLayout">
          {toasts.map((t) => {
            const pct = Math.max(0, Math.min(100, (t.remaining / t.duration) * 100));
            return (
              <motion.li
                key={t.id}
                role="status"
                layout={!reduce}
                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 20, scale: 0.96 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, x: 48, scale: 0.94 }}
                transition={toastSpring}
                onPointerEnter={() => setPause(t.id, "hover", true)}
                onPointerLeave={() => setPause(t.id, "hover", false)}
                onFocus={() => setPause(t.id, "focus", true)}
                onBlur={(e) => handleBlur(t.id, e)}
                className="pointer-events-auto relative overflow-hidden rounded-xl border border-slate-200 bg-white/95 shadow-lg shadow-slate-900/10 backdrop-blur dark:border-slate-700 dark:bg-slate-900/95 dark:shadow-black/40"
              >
                <div className="flex items-start gap-3 p-3 pr-2">
                  <span className="relative mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-100 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300">
                    <span
                      aria-hidden="true"
                      className="ta-pulse-ring absolute inset-0 rounded-lg bg-indigo-400/40 dark:bg-indigo-400/25"
                    />
                    <ArchiveIcon className="relative h-4 w-4" />
                  </span>

                  <div className="min-w-0 flex-1 pt-0.5">
                    <p className="text-sm font-semibold text-slate-900 dark:text-slate-50">
                      Conversation archived
                    </p>
                    <p className="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
                      {t.item.title}
                    </p>
                  </div>

                  <div className="flex shrink-0 items-center gap-0.5 pl-1">
                    <button
                      type="button"
                      onClick={() => undo(t.id)}
                      aria-label={`Undo archiving: ${t.item.title}`}
                      className="group relative inline-flex items-center gap-1.5 overflow-hidden rounded-lg px-2.5 py-1.5 text-xs font-semibold text-indigo-600 transition-colors hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-300 dark:hover:bg-indigo-500/15 dark:focus-visible:ring-offset-slate-900"
                    >
                      <UndoIcon className="h-3.5 w-3.5" />
                      Undo
                    </button>
                    <button
                      type="button"
                      onClick={() => dismiss(t.id)}
                      aria-label="Dismiss and keep archived"
                      className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
                    >
                      <CloseIcon className="h-4 w-4" />
                    </button>
                  </div>
                </div>

                {/* Countdown track */}
                <div className="h-1 w-full bg-slate-100 dark:bg-slate-800">
                  <div
                    className="relative h-full overflow-hidden rounded-r-full"
                    style={{ width: `${pct}%` }}
                  >
                    <span className="absolute inset-0 bg-indigo-500 dark:bg-indigo-400" />
                    <span className="ta-sheen absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-white/50 to-transparent" />
                  </div>
                </div>
              </motion.li>
            );
          })}
        </AnimatePresence>
      </ol>

      <style>{`
        @keyframes ta-pulse-ring {
          0%   { transform: scale(1);   opacity: 0.55; }
          70%  { transform: scale(1.55); opacity: 0; }
          100% { transform: scale(1.55); opacity: 0; }
        }
        @keyframes ta-sheen {
          0%   { transform: translateX(-140%); }
          100% { transform: translateX(520%); }
        }
        .ta-pulse-ring { animation: ta-pulse-ring 2.4s ease-out infinite; }
        .ta-sheen { animation: ta-sheen 1.8s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .ta-pulse-ring, .ta-sheen { animation: none !important; }
        }
      `}</style>
    </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 →