Web InnoventixFreeCode

Progress Toast

Original · free

toast with auto-dismiss progress

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

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

type ToastType = "success" | "error" | "info" | "warning";

interface ToastData {
  id: number;
  type: ToastType;
  title: string;
  message: string;
  duration: number;
}

interface ToastConfig {
  label: string;
  iconWrap: string;
  bar: string;
  trigger: string;
  icon: ReactNode;
}

const TOAST_CONFIG: Record<ToastType, ToastConfig> = {
  success: {
    label: "Success",
    iconWrap:
      "bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400",
    bar: "bg-emerald-500 dark:bg-emerald-400",
    trigger:
      "border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-300 dark:hover:bg-emerald-500/20 focus-visible:ring-emerald-500",
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
        strokeLinejoin="round"
        className="h-5 w-5"
        aria-hidden="true"
      >
        <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
        <path d="m22 4-10 10-3-3" />
      </svg>
    ),
  },
  error: {
    label: "Error",
    iconWrap:
      "bg-rose-100 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400",
    bar: "bg-rose-500 dark:bg-rose-400",
    trigger:
      "border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/25 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20 focus-visible:ring-rose-500",
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
        strokeLinejoin="round"
        className="h-5 w-5"
        aria-hidden="true"
      >
        <circle cx="12" cy="12" r="10" />
        <path d="m15 9-6 6" />
        <path d="m9 9 6 6" />
      </svg>
    ),
  },
  info: {
    label: "Info",
    iconWrap:
      "bg-sky-100 text-sky-600 dark:bg-sky-500/15 dark:text-sky-400",
    bar: "bg-sky-500 dark:bg-sky-400",
    trigger:
      "border-sky-200 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-500/25 dark:bg-sky-500/10 dark:text-sky-300 dark:hover:bg-sky-500/20 focus-visible:ring-sky-500",
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
        strokeLinejoin="round"
        className="h-5 w-5"
        aria-hidden="true"
      >
        <circle cx="12" cy="12" r="10" />
        <path d="M12 16v-4" />
        <path d="M12 8h.01" />
      </svg>
    ),
  },
  warning: {
    label: "Warning",
    iconWrap:
      "bg-amber-100 text-amber-600 dark:bg-amber-500/15 dark:text-amber-400",
    bar: "bg-amber-500 dark:bg-amber-400",
    trigger:
      "border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-300 dark:hover:bg-amber-500/20 focus-visible:ring-amber-500",
    icon: (
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
        strokeLinejoin="round"
        className="h-5 w-5"
        aria-hidden="true"
      >
        <path d="m10.29 3.86-8.18 14.14A2 2 0 0 0 3.83 21h16.34a2 2 0 0 0 1.72-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
        <path d="M12 9v4" />
        <path d="M12 17h.01" />
      </svg>
    ),
  },
};

const MESSAGES: Record<ToastType, ReadonlyArray<{ title: string; message: string }>> = {
  success: [
    { title: "Deployment live", message: "webinnoventix.com shipped to production in 42 seconds." },
    { title: "Payment received", message: "Invoice #2041 for $1,800 was settled by Northwind Studio." },
    { title: "Export ready", message: "Your 8,400-row analytics report finished. Download it now." },
  ],
  error: [
    { title: "Upload failed", message: "hero-banner.webp is 7.2 MB — over the 5 MB limit. Try a smaller file." },
    { title: "Connection lost", message: "We could not reach the API. Retrying in 5 seconds." },
    { title: "Payment declined", message: "Card ending 4242 was declined. Update your billing details." },
  ],
  info: [
    { title: "Sync in progress", message: "Importing 1,248 contacts from contacts.csv in the background." },
    { title: "New version available", message: "v2.4 is ready. Reload to get the latest editor improvements." },
    { title: "Scheduled maintenance", message: "Brief downtime is planned for Sunday 02:00–02:30 UTC." },
  ],
  warning: [
    { title: "Storage almost full", message: "You have used 92% of your 10 GB plan. Upgrade to avoid limits." },
    { title: "Session expiring", message: "You will be signed out in 3 minutes unless there is activity." },
    { title: "Unsaved changes", message: "Two edits are not saved yet. Leaving now will discard them." },
  ],
};

const TYPES: ReadonlyArray<ToastType> = ["success", "error", "info", "warning"];

const DURATIONS: ReadonlyArray<{ ms: number; label: string }> = [
  { ms: 3000, label: "3s" },
  { ms: 5000, label: "5s" },
  { ms: 8000, label: "8s" },
];

const MAX_TOASTS = 4;

interface ToastCardProps {
  toast: ToastData;
  reduced: boolean;
  onDismiss: (id: number) => void;
}

function ToastCard({ toast, reduced, onDismiss }: ToastCardProps) {
  const cfg = TOAST_CONFIG[toast.type];
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const [ariaValue, setAriaValue] = useState(100);
  const paused = hovered || focused;

  const pausedRef = useRef(false);
  const remainingRef = useRef(toast.duration);
  const barRef = useRef<HTMLDivElement | null>(null);
  const lastAriaRef = useRef(100);

  useEffect(() => {
    pausedRef.current = paused;
  }, [paused]);

  useEffect(() => {
    let raf = 0;
    let last = performance.now();

    const tick = (now: number) => {
      const dt = now - last;
      last = now;

      if (!pausedRef.current) {
        remainingRef.current = Math.max(0, remainingRef.current - dt);
      }

      const ratio = remainingRef.current / toast.duration;
      if (barRef.current) {
        barRef.current.style.transform = `scaleX(${ratio})`;
      }

      const pct = Math.round(ratio * 100);
      if (Math.abs(pct - lastAriaRef.current) >= 4 || pct === 0 || pct === 100) {
        lastAriaRef.current = pct;
        setAriaValue(pct);
      }

      if (remainingRef.current <= 0) {
        onDismiss(toast.id);
        return;
      }
      raf = requestAnimationFrame(tick);
    };

    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [toast.id, toast.duration, onDismiss]);

  const handleKeyDown = (event: KeyboardEvent<HTMLLIElement>) => {
    if (event.key === "Escape") {
      event.stopPropagation();
      onDismiss(toast.id);
    }
  };

  return (
    <motion.li
      layout
      initial={reduced ? { opacity: 0 } : { opacity: 0, x: 48, scale: 0.96 }}
      animate={reduced ? { opacity: 1 } : { opacity: 1, x: 0, scale: 1 }}
      exit={reduced ? { opacity: 0 } : { opacity: 0, x: 48, scale: 0.96 }}
      transition={
        reduced
          ? { duration: 0.15 }
          : { type: "spring", stiffness: 420, damping: 34, mass: 0.8 }
      }
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      onFocus={() => setFocused(true)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
          setFocused(false);
        }
      }}
      onKeyDown={handleKeyDown}
      role="group"
      aria-label={`${cfg.label} notification`}
      className="pointer-events-auto overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-lg shadow-zinc-900/10 ring-1 ring-black/[0.02] dark:border-zinc-700/60 dark:bg-zinc-900 dark:shadow-black/40"
    >
      <div className="flex items-start gap-3 p-4">
        <span
          className={`mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-lg ${cfg.iconWrap} ${reduced ? "" : "tprog-pop"}`}
        >
          {cfg.icon}
        </span>

        <div className="min-w-0 flex-1">
          <div className="flex items-center gap-2">
            <p className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-50">
              {toast.title}
            </p>
            {paused && (
              <span className="inline-flex items-center gap-1 rounded-full bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
                <svg viewBox="0 0 24 24" fill="currentColor" className="h-2.5 w-2.5" aria-hidden="true">
                  <rect x="6" y="4" width="4" height="16" rx="1" />
                  <rect x="14" y="4" width="4" height="16" rx="1" />
                </svg>
                Paused
              </span>
            )}
          </div>
          <p className="mt-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
            {toast.message}
          </p>
        </div>

        <button
          type="button"
          onClick={() => onDismiss(toast.id)}
          aria-label={`Dismiss ${cfg.label.toLowerCase()} notification`}
          className="-mr-1 -mt-1 grid h-7 w-7 shrink-0 place-items-center rounded-md text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-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:hover:bg-zinc-800 dark:hover:text-zinc-200 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
        >
          <svg
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth={2}
            strokeLinecap="round"
            strokeLinejoin="round"
            className="h-4 w-4"
            aria-hidden="true"
          >
            <path d="M18 6 6 18" />
            <path d="m6 6 12 12" />
          </svg>
        </button>
      </div>

      <div
        role="progressbar"
        aria-label="Time remaining before dismiss"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={ariaValue}
        className="relative h-1.5 w-full overflow-hidden bg-zinc-100 dark:bg-zinc-800"
      >
        <div
          ref={barRef}
          className={`h-full origin-left rounded-r-full ${cfg.bar}`}
          style={{ transform: "scaleX(1)" }}
        />
        {!reduced && (
          <span
            aria-hidden="true"
            className="tprog-shimmer pointer-events-none absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/50 to-transparent dark:via-white/20"
            style={{ animationPlayState: paused ? "paused" : "running" }}
          />
        )}
      </div>
    </motion.li>
  );
}

export default function ToastProgress() {
  const prefersReduced = useReducedMotion();
  const reduced = prefersReduced ?? false;

  const [toasts, setToasts] = useState<ToastData[]>([]);
  const [duration, setDuration] = useState<number>(5000);
  const [announce, setAnnounce] = useState("");

  const idRef = useRef(0);
  const cursorRef = useRef<Record<ToastType, number>>({
    success: 0,
    error: 0,
    info: 0,
    warning: 0,
  });
  const durationRef = useRef(duration);
  const durationBtnRefs = useRef<Array<HTMLButtonElement | null>>([]);

  useEffect(() => {
    durationRef.current = duration;
  }, [duration]);

  const addToast = useCallback((type: ToastType) => {
    const pool = MESSAGES[type];
    const index = cursorRef.current[type] % pool.length;
    cursorRef.current[type] += 1;
    const { title, message } = pool[index];
    idRef.current += 1;
    const id = idRef.current;

    setToasts((prev) =>
      [...prev, { id, type, title, message, duration: durationRef.current }].slice(-MAX_TOASTS),
    );
    setAnnounce(`${TOAST_CONFIG[type].label}: ${title}. ${message}`);
  }, []);

  const removeToast = useCallback((id: number) => {
    setToasts((prev) => prev.filter((toast) => toast.id !== id));
  }, []);

  const clearAll = useCallback(() => {
    setToasts([]);
    setAnnounce("All notifications dismissed.");
  }, []);

  const handleDurationKey = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
    let next = index;
    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      next = (index + 1) % DURATIONS.length;
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      next = (index - 1 + DURATIONS.length) % DURATIONS.length;
    } else {
      return;
    }
    event.preventDefault();
    setDuration(DURATIONS[next].ms);
    durationBtnRefs.current[next]?.focus();
  };

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-zinc-50 to-white px-4 py-16 text-zinc-900 dark:from-zinc-950 dark:to-zinc-900 dark:text-zinc-50 sm:px-6 sm:py-20">
      <style>{`
        @keyframes tprog-shimmer {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(420%); }
        }
        @keyframes tprog-pop {
          0% { transform: scale(0.5); opacity: 0; }
          60% { transform: scale(1.08); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        .tprog-shimmer { animation: tprog-shimmer 1.8s linear infinite; }
        .tprog-pop { animation: tprog-pop 0.42s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .tprog-shimmer, .tprog-pop { animation: none !important; }
        }
      `}</style>

      <div aria-live="polite" role="status" className="sr-only">
        {announce}
      </div>

      <div className="mx-auto max-w-5xl">
        <div className="max-w-2xl">
          <span className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
            Notifications
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight sm:text-4xl">
            Toasts that show their own clock
          </h2>
          <p className="mt-3 text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
            Every toast auto-dismisses on a countdown you can watch. Hover, focus, or tab into one
            and the timer pauses, so nothing vanishes while you are still reading it.
          </p>
        </div>

        <div className="relative mt-10 min-h-[460px] overflow-hidden rounded-2xl border border-zinc-200 bg-white/60 p-6 backdrop-blur-sm dark:border-zinc-800 dark:bg-zinc-900/40 sm:p-10">
          <div
            aria-hidden="true"
            className="pointer-events-none absolute -right-16 -top-16 h-64 w-64 rounded-full bg-indigo-400/10 blur-3xl dark:bg-indigo-500/10"
          />

          <div className="relative mx-auto max-w-md text-center">
            <p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
              Trigger a notification
            </p>
            <div className="mt-4 flex flex-wrap justify-center gap-2.5">
              {TYPES.map((type) => (
                <button
                  key={type}
                  type="button"
                  onClick={() => addToast(type)}
                  className={`inline-flex items-center gap-2 rounded-lg border px-3.5 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${TOAST_CONFIG[type].trigger}`}
                >
                  <span className="h-4 w-4">{TOAST_CONFIG[type].icon}</span>
                  {TOAST_CONFIG[type].label}
                </button>
              ))}
            </div>

            <div className="mt-8 flex flex-col items-center gap-3">
              <div className="flex items-center gap-3">
                <span
                  id="tprog-duration-label"
                  className="text-sm font-medium text-zinc-500 dark:text-zinc-400"
                >
                  Auto-dismiss after
                </span>
                <div
                  role="radiogroup"
                  aria-labelledby="tprog-duration-label"
                  className="inline-flex rounded-lg border border-zinc-200 bg-zinc-100 p-1 dark:border-zinc-700/60 dark:bg-zinc-800/60"
                >
                  {DURATIONS.map((option, index) => {
                    const selected = option.ms === duration;
                    return (
                      <button
                        key={option.ms}
                        type="button"
                        role="radio"
                        aria-checked={selected}
                        tabIndex={selected ? 0 : -1}
                        ref={(el) => {
                          durationBtnRefs.current[index] = el;
                        }}
                        onClick={() => setDuration(option.ms)}
                        onKeyDown={(event) => handleDurationKey(event, index)}
                        className={`rounded-md px-3 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-800 ${
                          selected
                            ? "bg-zinc-900 text-white shadow-sm dark:bg-white dark:text-zinc-900"
                            : "text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
                        }`}
                      >
                        {option.label}
                      </button>
                    );
                  })}
                </div>
              </div>

              <div className="flex items-center gap-3 text-sm">
                <span className="text-zinc-500 dark:text-zinc-400" aria-live="off">
                  {toasts.length} active
                </span>
                <span aria-hidden="true" className="text-zinc-300 dark:text-zinc-700">
                  •
                </span>
                <button
                  type="button"
                  onClick={clearAll}
                  disabled={toasts.length === 0}
                  className="font-medium text-zinc-600 underline-offset-4 transition hover:underline disabled:cursor-not-allowed disabled:text-zinc-300 disabled:no-underline 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-zinc-300 dark:disabled:text-zinc-700 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
                >
                  Clear all
                </button>
              </div>

              <p className="mt-1 text-xs text-zinc-400 dark:text-zinc-500">
                Tip: press Escape while a toast is focused to close it.
              </p>
            </div>
          </div>

          <ul
            aria-label="Notifications"
            className="pointer-events-none absolute bottom-5 right-5 z-10 flex w-[336px] max-w-[calc(100%-2.5rem)] flex-col gap-3"
          >
            <AnimatePresence initial={false}>
              {toasts.map((toast) => (
                <ToastCard
                  key={toast.id}
                  toast={toast}
                  reduced={reduced}
                  onDismiss={removeToast}
                />
              ))}
            </AnimatePresence>
          </ul>
        </div>
      </div>
    </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 →