Web InnoventixFreeCode

Toast Stack Alert

Original · free

A stack of dismissible toasts in a corner.

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

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

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

interface Toast {
  id: number;
  variant: ToastVariant;
  title: string;
  message: string;
  action?: { label: string; onAction: () => void };
  duration: number;
}

interface VariantStyle {
  ring: string;
  iconWrap: string;
  iconColor: string;
  bar: string;
  accent: string;
  icon: ReactNode;
}

const VARIANTS: Record<ToastVariant, VariantStyle> = {
  success: {
    ring: "ring-emerald-500/20 dark:ring-emerald-400/20",
    iconWrap: "bg-emerald-50 dark:bg-emerald-500/10",
    iconColor: "text-emerald-600 dark:text-emerald-400",
    bar: "bg-emerald-500 dark:bg-emerald-400",
    accent: "text-emerald-700 dark:text-emerald-300",
    icon: (
      <svg viewBox="0 0 20 20" fill="none" className="h-5 w-5" aria-hidden="true">
        <path
          d="M6 10.5l2.5 2.5L14.5 7"
          stroke="currentColor"
          strokeWidth="1.75"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
        <circle cx="10" cy="10" r="8.25" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
      </svg>
    ),
  },
  error: {
    ring: "ring-rose-500/20 dark:ring-rose-400/20",
    iconWrap: "bg-rose-50 dark:bg-rose-500/10",
    iconColor: "text-rose-600 dark:text-rose-400",
    bar: "bg-rose-500 dark:bg-rose-400",
    accent: "text-rose-700 dark:text-rose-300",
    icon: (
      <svg viewBox="0 0 20 20" fill="none" className="h-5 w-5" aria-hidden="true">
        <path d="M10 6v5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
        <circle cx="10" cy="13.75" r="1" fill="currentColor" />
        <circle cx="10" cy="10" r="8.25" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
      </svg>
    ),
  },
  warning: {
    ring: "ring-amber-500/20 dark:ring-amber-400/20",
    iconWrap: "bg-amber-50 dark:bg-amber-500/10",
    iconColor: "text-amber-600 dark:text-amber-400",
    bar: "bg-amber-500 dark:bg-amber-400",
    accent: "text-amber-700 dark:text-amber-300",
    icon: (
      <svg viewBox="0 0 20 20" fill="none" className="h-5 w-5" aria-hidden="true">
        <path
          d="M10 2.75L18 16.5H2L10 2.75z"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinejoin="round"
          opacity="0.4"
        />
        <path d="M10 8v3.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
        <circle cx="10" cy="14" r="1" fill="currentColor" />
      </svg>
    ),
  },
  info: {
    ring: "ring-sky-500/20 dark:ring-sky-400/20",
    iconWrap: "bg-sky-50 dark:bg-sky-500/10",
    iconColor: "text-sky-600 dark:text-sky-400",
    bar: "bg-sky-500 dark:bg-sky-400",
    accent: "text-sky-700 dark:text-sky-300",
    icon: (
      <svg viewBox="0 0 20 20" fill="none" className="h-5 w-5" aria-hidden="true">
        <path d="M10 9.25v4.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
        <circle cx="10" cy="6.5" r="1" fill="currentColor" />
        <circle cx="10" cy="10" r="8.25" stroke="currentColor" strokeWidth="1.5" opacity="0.4" />
      </svg>
    ),
  },
};

const PRESETS: Omit<Toast, "id">[] = [
  {
    variant: "success",
    title: "Payment received",
    message: "Invoice #4021 for $2,400 was settled by Northwind Ltd.",
    duration: 5000,
    action: { label: "View receipt", onAction: () => {} },
  },
  {
    variant: "error",
    title: "Deploy failed",
    message: "Build step exited with code 1 — check the CI logs for details.",
    duration: 6000,
    action: { label: "Retry", onAction: () => {} },
  },
  {
    variant: "warning",
    title: "Storage almost full",
    message: "You've used 92% of your 50 GB plan. Uploads may soon be paused.",
    duration: 6000,
    action: { label: "Upgrade", onAction: () => {} },
  },
  {
    variant: "info",
    title: "New comment",
    message: "Priya mentioned you on the “Q3 roadmap” document.",
    duration: 5000,
    action: { label: "Open", onAction: () => {} },
  },
];

function ToastCard({
  toast,
  onDismiss,
  reduced,
}: {
  toast: Toast;
  onDismiss: (id: number) => void;
  reduced: boolean;
}) {
  const v = VARIANTS[toast.variant];
  const [paused, setPaused] = useState(false);
  const remainingRef = useRef(toast.duration);
  const startRef = useRef<number>(0);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const clear = useCallback(() => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
      timerRef.current = null;
    }
  }, []);

  useEffect(() => {
    if (paused) {
      clear();
      remainingRef.current -= Date.now() - startRef.current;
      return;
    }
    startRef.current = Date.now();
    timerRef.current = setTimeout(() => onDismiss(toast.id), remainingRef.current);
    return clear;
  }, [paused, clear, onDismiss, toast.id]);

  return (
    <motion.li
      layout
      initial={reduced ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.96 }}
      animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
      exit={reduced ? { opacity: 0 } : { opacity: 0, x: 60, scale: 0.9 }}
      transition={{ type: "spring", stiffness: 460, damping: 34, mass: 0.7 }}
      onMouseEnter={() => setPaused(true)}
      onMouseLeave={() => setPaused(false)}
      onFocusCapture={() => setPaused(true)}
      onBlurCapture={() => setPaused(false)}
      role="status"
      aria-live={toast.variant === "error" ? "assertive" : "polite"}
      className="pointer-events-auto relative w-full overflow-hidden rounded-2xl bg-white text-slate-900 shadow-lg shadow-slate-900/10 ring-1 ring-inset ring-slate-950/5 backdrop-blur dark:bg-slate-900 dark:text-slate-100 dark:shadow-black/40 dark:ring-white/10"
    >
      <div className={`pointer-events-none absolute inset-0 rounded-2xl ring-1 ring-inset ${v.ring}`} />
      <div className="flex items-start gap-3 p-4">
        <span
          className={`mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-full ${v.iconWrap} ${v.iconColor}`}
        >
          {v.icon}
        </span>

        <div className="min-w-0 flex-1">
          <p className="text-sm font-semibold leading-5">{toast.title}</p>
          <p className="mt-0.5 text-sm leading-5 text-slate-600 dark:text-slate-400">{toast.message}</p>

          {toast.action && (
            <button
              type="button"
              onClick={() => {
                toast.action?.onAction();
                onDismiss(toast.id);
              }}
              className={`mt-2 rounded-md text-sm font-semibold underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${v.accent} focus-visible:ring-slate-400 dark:focus-visible:ring-slate-500`}
            >
              {toast.action.label}
            </button>
          )}
        </div>

        <button
          type="button"
          onClick={() => onDismiss(toast.id)}
          aria-label={`Dismiss ${toast.title}`}
          className="-mr-1 -mt-1 grid h-7 w-7 shrink-0 place-items-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-300 dark:focus-visible:ring-slate-500"
        >
          <svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
            <path
              d="M5 5l10 10M15 5L5 15"
              stroke="currentColor"
              strokeWidth="1.75"
              strokeLinecap="round"
            />
          </svg>
        </button>
      </div>

      {!reduced && (
        <div className="absolute inset-x-0 bottom-0 h-1 bg-slate-100 dark:bg-slate-800">
          <span
            key={paused ? "paused" : "running"}
            className={`atk-bar block h-full origin-left ${v.bar}`}
            style={{
              animationName: paused ? "none" : "atk-shrink",
              animationDuration: `${remainingRef.current}ms`,
            }}
          />
        </div>
      )}
    </motion.li>
  );
}

export default function AlertToastStack() {
  const reduced = useReducedMotion() ?? false;
  const [toasts, setToasts] = useState<Toast[]>([]);
  const nextId = useRef(1);
  const cursor = useRef(0);

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

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

  const pushNext = useCallback(() => {
    const preset = PRESETS[cursor.current % PRESETS.length];
    cursor.current += 1;
    push(preset);
  }, [push]);

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 dark:bg-slate-950 sm:py-28">
      <style>{`
        @keyframes atk-shrink {
          from { transform: scaleX(1); }
          to { transform: scaleX(0); }
        }
        .atk-bar {
          animation-timing-function: linear;
          animation-fill-mode: forwards;
        }
        @media (prefers-reduced-motion: reduce) {
          .atk-bar { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-2xl text-center">
        <span className="inline-flex items-center rounded-full bg-indigo-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700 ring-1 ring-inset ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/25">
          Notifications
        </span>
        <h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
          Toast stack
        </h2>
        <p className="mt-3 text-base leading-7 text-slate-600 dark:text-slate-400">
          Fire a notification and watch it stack in the corner. Hover to pause, click the close icon
          or an action to dismiss.
        </p>

        <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
          <button
            type="button"
            onClick={pushNext}
            className="rounded-xl bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-transform hover:-translate-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:bg-white dark:text-slate-900 dark:focus-visible:ring-offset-slate-950"
          >
            Add toast
          </button>
          {(["success", "error", "warning", "info"] as const).map((variant) => {
            const preset = PRESETS.find((p) => p.variant === variant)!;
            const v = VARIANTS[variant];
            return (
              <button
                key={variant}
                type="button"
                onClick={() => push(preset)}
                className={`inline-flex items-center gap-1.5 rounded-xl bg-white px-4 py-2.5 text-sm font-semibold capitalize shadow-sm ring-1 ring-inset ring-slate-200 transition-transform hover:-translate-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:bg-slate-900 dark:ring-slate-700 dark:focus-visible:ring-offset-slate-950 ${v.accent}`}
              >
                <span className={v.iconColor}>{v.icon}</span>
                {variant}
              </button>
            );
          })}
          {toasts.length > 0 && (
            <button
              type="button"
              onClick={() => setToasts([])}
              className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-500 transition-colors hover:text-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 dark:text-slate-400 dark:hover:text-slate-200"
            >
              Clear all
            </button>
          )}
        </div>
      </div>

      <div
        aria-live="polite"
        aria-label="Notifications"
        className="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex justify-center px-4 pb-4 sm:inset-x-auto sm:right-6 sm:bottom-6 sm:justify-end sm:px-0"
      >
        <ul className="flex w-full max-w-sm flex-col-reverse gap-3">
          <AnimatePresence initial={false} mode="popLayout">
            {toasts.map((toast) => (
              <ToastCard key={toast.id} toast={toast} onDismiss={dismiss} reduced={reduced} />
            ))}
          </AnimatePresence>
        </ul>
      </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 →