Web InnoventixFreeCode

Progress Alert

Original · free

An alert with a countdown progress bar.

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

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

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

interface AlertItem {
  id: number;
  tone: AlertTone;
  title: string;
  message: string;
  action: string;
  duration: number;
}

interface ToneStyle {
  ring: string;
  accent: string;
  bar: string;
  iconWrap: string;
  actionBtn: string;
  live: "assertive" | "polite";
  role: "alert" | "status";
  icon: ReactNode;
}

const TONES: Record<AlertTone, ToneStyle> = {
  success: {
    ring: "border-emerald-200 dark:border-emerald-500/25",
    accent: "text-emerald-700 dark:text-emerald-300",
    bar: "bg-emerald-500 dark:bg-emerald-400",
    iconWrap:
      "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
    actionBtn:
      "text-emerald-700 hover:bg-emerald-100 focus-visible:ring-emerald-500 dark:text-emerald-300 dark:hover:bg-emerald-500/15",
    live: "polite",
    role: "status",
    icon: (
      <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
        <path
          d="m5 13 4 4L19 7"
          stroke="currentColor"
          strokeWidth="2.2"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    ),
  },
  info: {
    ring: "border-sky-200 dark:border-sky-500/25",
    accent: "text-sky-700 dark:text-sky-300",
    bar: "bg-sky-500 dark:bg-sky-400",
    iconWrap: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
    actionBtn:
      "text-sky-700 hover:bg-sky-100 focus-visible:ring-sky-500 dark:text-sky-300 dark:hover:bg-sky-500/15",
    live: "polite",
    role: "status",
    icon: (
      <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
        <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
        <path
          d="M12 11v5"
          stroke="currentColor"
          strokeWidth="2.2"
          strokeLinecap="round"
        />
        <circle cx="12" cy="7.5" r="1.3" fill="currentColor" />
      </svg>
    ),
  },
  warning: {
    ring: "border-amber-200 dark:border-amber-500/25",
    accent: "text-amber-700 dark:text-amber-300",
    bar: "bg-amber-500 dark:bg-amber-400",
    iconWrap: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
    actionBtn:
      "text-amber-700 hover:bg-amber-100 focus-visible:ring-amber-500 dark:text-amber-300 dark:hover:bg-amber-500/15",
    live: "assertive",
    role: "alert",
    icon: (
      <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
        <path
          d="M12 4.5 21 19H3L12 4.5Z"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinejoin="round"
        />
        <path
          d="M12 10v4"
          stroke="currentColor"
          strokeWidth="2.2"
          strokeLinecap="round"
        />
        <circle cx="12" cy="16.6" r="1.2" fill="currentColor" />
      </svg>
    ),
  },
  error: {
    ring: "border-rose-200 dark:border-rose-500/25",
    accent: "text-rose-700 dark:text-rose-300",
    bar: "bg-rose-500 dark:bg-rose-400",
    iconWrap: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
    actionBtn:
      "text-rose-700 hover:bg-rose-100 focus-visible:ring-rose-500 dark:text-rose-300 dark:hover:bg-rose-500/15",
    live: "assertive",
    role: "alert",
    icon: (
      <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
        <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
        <path
          d="m9 9 6 6M15 9l-6 6"
          stroke="currentColor"
          strokeWidth="2.2"
          strokeLinecap="round"
        />
      </svg>
    ),
  },
};

const PRESETS: Omit<AlertItem, "id">[] = [
  {
    tone: "success",
    title: "Payment received",
    message:
      "Invoice #INV-2043 has been paid in full. A receipt is on its way to your inbox.",
    action: "View receipt",
    duration: 7000,
  },
  {
    tone: "info",
    title: "New version available",
    message:
      "v2.4 ships faster builds and a fix for dark-mode contrast. Reload to update.",
    action: "Reload now",
    duration: 8000,
  },
  {
    tone: "warning",
    title: "Session expiring soon",
    message:
      "You'll be signed out shortly for security. Save your work to avoid losing changes.",
    action: "Stay signed in",
    duration: 9000,
  },
  {
    tone: "error",
    title: "Upload failed",
    message:
      "photo-shoot-final.zip couldn't be uploaded. Check your connection and try again.",
    action: "Retry upload",
    duration: 9000,
  },
];

function ProgressAlert({
  item,
  onClose,
}: {
  item: AlertItem;
  onClose: (id: number) => void;
}) {
  const tone = TONES[item.tone];
  const [remaining, setRemaining] = useState(item.duration);
  const [paused, setPaused] = useState(false);

  const remainingRef = useRef(item.duration);
  const pausedRef = useRef(false);
  const closeRef = useRef(onClose);
  closeRef.current = onClose;

  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);
        setRemaining(remainingRef.current);
        if (remainingRef.current <= 0) {
          closeRef.current(item.id);
          return;
        }
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [item.id]);

  const pct = Math.max(0, Math.min(100, (remaining / item.duration) * 100));
  const secs = Math.ceil(remaining / 1000);

  const pause = useCallback(() => setPaused(true), []);
  const resume = useCallback(() => setPaused(false), []);

  return (
    <div
      role={tone.role}
      aria-live={tone.live}
      onMouseEnter={pause}
      onMouseLeave={resume}
      onFocusCapture={pause}
      onBlurCapture={resume}
      className={`group relative overflow-hidden rounded-xl border bg-white shadow-lg shadow-slate-900/5 dark:bg-slate-900 dark:shadow-black/40 ${tone.ring}`}
    >
      <div className="flex items-start gap-3 p-4 pr-3">
        <span
          className={`mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-lg ${tone.iconWrap}`}
        >
          {tone.icon}
        </span>

        <div className="min-w-0 flex-1">
          <div className="flex items-start justify-between gap-2">
            <p className="text-sm font-semibold text-slate-900 dark:text-slate-50">
              {item.title}
            </p>
            <span
              className={`shrink-0 tabular-nums text-[11px] font-medium ${
                paused ? "text-slate-400 dark:text-slate-500" : tone.accent
              }`}
              aria-hidden="true"
            >
              {paused ? "paused" : `${secs}s`}
            </span>
          </div>
          <p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
            {item.message}
          </p>
          <div className="mt-3 flex items-center gap-1">
            <button
              type="button"
              onClick={() => onClose(item.id)}
              className={`rounded-md px-2.5 py-1.5 text-xs font-semibold outline-none transition focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${tone.actionBtn}`}
            >
              {item.action}
            </button>
            <button
              type="button"
              onClick={() => onClose(item.id)}
              className="rounded-md px-2.5 py-1.5 text-xs font-medium text-slate-500 outline-none transition hover:bg-slate-100 hover:text-slate-700 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:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
            >
              Dismiss
            </button>
          </div>
        </div>

        <button
          type="button"
          onClick={() => onClose(item.id)}
          aria-label={`Close: ${item.title}`}
          className="-mr-1 grid h-7 w-7 shrink-0 place-items-center rounded-md text-slate-400 outline-none transition hover:bg-slate-100 hover:text-slate-600 focus-visible:ring-2 focus-visible:ring-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
        >
          <svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
            <path
              d="M6 6l12 12M18 6 6 18"
              stroke="currentColor"
              strokeWidth="2"
              strokeLinecap="round"
            />
          </svg>
        </button>
      </div>

      <div
        className="absolute inset-x-0 bottom-0 h-1 bg-slate-100 dark:bg-slate-800"
        role="progressbar"
        aria-label="Time remaining before this alert dismisses"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={Math.round(pct)}
      >
        <div
          className={`apbar-fill h-full ${tone.bar}`}
          style={{ width: `${pct}%`, opacity: paused ? 0.55 : 1 }}
        />
      </div>
    </div>
  );
}

export default function AlertProgress() {
  const reduced = useReducedMotion();
  const [items, setItems] = useState<AlertItem[]>([]);
  const nextId = useRef(1);

  const push = useCallback((preset: Omit<AlertItem, "id">) => {
    setItems((prev) => {
      const id = nextId.current++;
      const next = [{ ...preset, id }, ...prev];
      return next.slice(0, 4);
    });
  }, []);

  const close = useCallback((id: number) => {
    setItems((prev) => prev.filter((a) => a.id !== id));
  }, []);

  useEffect(() => {
    const id = nextId.current++;
    setItems([{ ...PRESETS[0], id }]);
  }, []);

  const motionProps = reduced
    ? {}
    : {
        initial: { opacity: 0, y: 14, scale: 0.97 },
        animate: { opacity: 1, y: 0, scale: 1 },
        exit: { opacity: 0, x: 32, scale: 0.97 },
        transition: { type: "spring" as const, stiffness: 420, damping: 34 },
        layout: true,
      };

  return (
    <section className="relative w-full bg-slate-50 px-6 py-20 dark:bg-slate-950 sm:py-24">
      <style>{`
        @keyframes apbar-sheen {
          0% { transform: translateX(-100%); }
          100% { transform: translateX(320%); }
        }
        .apbar-fill { position: relative; overflow: hidden; }
        .apbar-fill::after {
          content: "";
          position: absolute;
          inset: 0;
          width: 40%;
          background: linear-gradient(90deg, transparent, rgba(255,255,255,0.55), transparent);
          animation: apbar-sheen 2.2s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .apbar-fill::after { animation: none; display: none; }
        }
      `}</style>

      <div className="mx-auto max-w-xl">
        <div className="mb-8 text-center">
          <span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Auto-dismiss alerts
          </span>
          <h2 className="mt-4 text-2xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
            Toasts with a countdown
          </h2>
          <p className="mt-2 text-sm text-slate-600 dark:text-slate-400">
            Each alert clears itself when the bar runs out. Hover or focus to pause
            the timer.
          </p>
        </div>

        <div className="mb-6 flex flex-wrap justify-center gap-2">
          {PRESETS.map((p) => (
            <button
              key={p.tone}
              type="button"
              onClick={() => push(p)}
              className="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold capitalize text-slate-700 outline-none transition hover:border-slate-300 hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950"
            >
              {p.tone}
            </button>
          ))}
        </div>

        <ul className="flex flex-col gap-3" aria-label="Notifications">
          <AnimatePresence initial={false}>
            {items.map((item) => (
              <motion.li key={item.id} {...motionProps} className="list-none">
                <ProgressAlert item={item} onClose={close} />
              </motion.li>
            ))}
          </AnimatePresence>
        </ul>

        {items.length === 0 && (
          <p className="rounded-xl border border-dashed border-slate-300 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
            All caught up. Trigger an alert above.
          </p>
        )}
      </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 →