Web InnoventixFreeCode

Toast Slide Alert

Original · free

A toast that slides in with an icon and auto-dismiss progress.

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

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

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

type ToastItem = {
  id: number;
  tone: ToastTone;
  title: string;
  message: string;
  duration: number;
};

type ToneStyle = {
  ring: string;
  iconWrap: string;
  bar: string;
  icon: ReactNode;
  label: string;
};

const TONES: Record<ToastTone, ToneStyle> = {
  success: {
    ring: "ring-emerald-200 dark:ring-emerald-500/30",
    iconWrap:
      "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
    bar: "bg-emerald-500 dark:bg-emerald-400",
    label: "Success",
    icon: (
      <svg viewBox="0 0 20 20" fill="none" className="h-5 w-5" aria-hidden="true">
        <path
          d="M4 10.5 8.2 14.5 16 6"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    ),
  },
  error: {
    ring: "ring-rose-200 dark:ring-rose-500/30",
    iconWrap:
      "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
    bar: "bg-rose-500 dark:bg-rose-400",
    label: "Error",
    icon: (
      <svg viewBox="0 0 20 20" fill="none" className="h-5 w-5" aria-hidden="true">
        <path
          d="M6.5 6.5 13.5 13.5M13.5 6.5 6.5 13.5"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    ),
  },
  info: {
    ring: "ring-sky-200 dark:ring-sky-500/30",
    iconWrap:
      "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
    bar: "bg-sky-500 dark:bg-sky-400",
    label: "Info",
    icon: (
      <svg viewBox="0 0 20 20" fill="none" className="h-5 w-5" aria-hidden="true">
        <circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.8" />
        <path
          d="M10 9v4.5"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
        />
        <circle cx="10" cy="6.4" r="1.05" fill="currentColor" />
      </svg>
    ),
  },
  warning: {
    ring: "ring-amber-200 dark:ring-amber-500/30",
    iconWrap:
      "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
    bar: "bg-amber-500 dark:bg-amber-400",
    label: "Warning",
    icon: (
      <svg viewBox="0 0 20 20" fill="none" className="h-5 w-5" aria-hidden="true">
        <path
          d="M10 2.8 18 16.2H2L10 2.8Z"
          stroke="currentColor"
          strokeWidth="1.8"
          strokeLinejoin="round"
        />
        <path
          d="M10 8v3.4"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
        />
        <circle cx="10" cy="13.7" r="1.05" fill="currentColor" />
      </svg>
    ),
  },
};

const PRESETS: Omit<ToastItem, "id">[] = [
  {
    tone: "success",
    title: "Deployment live",
    message: "web-innoventix-v3 shipped to production in 42s.",
    duration: 5000,
  },
  {
    tone: "info",
    title: "New comment",
    message: "Salman replied to your thread on the pricing strip.",
    duration: 5500,
  },
  {
    tone: "warning",
    title: "Storage almost full",
    message: "You've used 92% of your media library quota.",
    duration: 6000,
  },
  {
    tone: "error",
    title: "Payment declined",
    message: "Card ending 4417 was rejected. Update billing to retry.",
    duration: 6500,
  },
];

const BUTTON_TONES: { tone: ToastTone; label: string; classes: string }[] = [
  {
    tone: "success",
    label: "Success",
    classes:
      "bg-emerald-600 text-white hover:bg-emerald-500 focus-visible:outline-emerald-600 dark:bg-emerald-500 dark:hover:bg-emerald-400",
  },
  {
    tone: "info",
    label: "Info",
    classes:
      "bg-sky-600 text-white hover:bg-sky-500 focus-visible:outline-sky-600 dark:bg-sky-500 dark:hover:bg-sky-400",
  },
  {
    tone: "warning",
    label: "Warning",
    classes:
      "bg-amber-500 text-white hover:bg-amber-400 focus-visible:outline-amber-500 dark:bg-amber-500 dark:hover:bg-amber-400",
  },
  {
    tone: "error",
    label: "Error",
    classes:
      "bg-rose-600 text-white hover:bg-rose-500 focus-visible:outline-rose-600 dark:bg-rose-500 dark:hover:bg-rose-400",
  },
];

function Toast({
  item,
  onDismiss,
  reduce,
}: {
  item: ToastItem;
  onDismiss: (id: number) => void;
  reduce: boolean;
}) {
  const tone = TONES[item.tone];
  const [paused, setPaused] = useState(false);
  const [remaining, setRemaining] = useState(item.duration);
  const startRef = useRef<number>(Date.now());
  const leftRef = useRef<number>(item.duration);
  const rafRef = useRef<number | null>(null);

  useEffect(() => {
    if (paused) {
      leftRef.current = remaining;
      return;
    }
    startRef.current = Date.now();
    const tick = () => {
      const elapsed = Date.now() - startRef.current;
      const next = Math.max(0, leftRef.current - elapsed);
      setRemaining(next);
      if (next <= 0) {
        onDismiss(item.id);
        return;
      }
      rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [paused, item.id]);

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

  const variants: Variants = {
    hidden: { opacity: 0, x: reduce ? 0 : 48, scale: reduce ? 1 : 0.96 },
    show: {
      opacity: 1,
      x: 0,
      scale: 1,
      transition: { type: "spring", stiffness: 420, damping: 32, mass: 0.7 },
    },
    exit: {
      opacity: 0,
      x: reduce ? 0 : 48,
      scale: reduce ? 1 : 0.96,
      transition: { duration: 0.2, ease: "easeIn" },
    },
  };

  return (
    <motion.div
      layout={!reduce}
      variants={variants}
      initial="hidden"
      animate="show"
      exit="exit"
      role={item.tone === "error" ? "alert" : "status"}
      aria-live={item.tone === "error" ? "assertive" : "polite"}
      onMouseEnter={() => setPaused(true)}
      onMouseLeave={() => setPaused(false)}
      onFocusCapture={() => setPaused(true)}
      onBlurCapture={() => setPaused(false)}
      className={[
        "pointer-events-auto relative w-full max-w-sm overflow-hidden rounded-xl",
        "bg-white/95 ring-1 shadow-lg shadow-slate-900/5 backdrop-blur",
        "dark:bg-slate-900/95 dark:shadow-black/30",
        tone.ring,
      ].join(" ")}
    >
      <div className="flex items-start gap-3 p-4">
        <span
          className={[
            "mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
            tone.iconWrap,
          ].join(" ")}
        >
          {tone.icon}
        </span>

        <div className="min-w-0 flex-1">
          <p className="text-sm font-semibold text-slate-900 dark:text-slate-50">
            {item.title}
          </p>
          <p className="mt-0.5 text-sm leading-snug text-slate-600 dark:text-slate-300">
            {item.message}
          </p>
        </div>

        <button
          type="button"
          onClick={() => onDismiss(item.id)}
          aria-label={`Dismiss ${tone.label.toLowerCase()} notification`}
          className={[
            "-mr-1 -mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
            "text-slate-400 transition hover:bg-slate-100 hover:text-slate-700",
            "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-200",
            "dark:focus-visible:ring-slate-500",
          ].join(" ")}
        >
          <svg viewBox="0 0 20 20" fill="none" className="h-4 w-4" aria-hidden="true">
            <path
              d="M5.5 5.5 14.5 14.5M14.5 5.5 5.5 14.5"
              stroke="currentColor"
              strokeWidth="1.8"
              strokeLinecap="round"
            />
          </svg>
        </button>
      </div>

      <div
        className="h-1 w-full bg-slate-100 dark:bg-slate-800"
        role="presentation"
      >
        <div
          className={["h-full rounded-r-full", tone.bar].join(" ")}
          style={{
            width: `${pct}%`,
            transition: paused ? "none" : "width 90ms linear",
          }}
        />
      </div>
    </motion.div>
  );
}

export default function AlertToastSlide() {
  const reduce = useReducedMotion() ?? false;
  const [toasts, setToasts] = useState<ToastItem[]>([]);
  const idRef = useRef(0);
  const cycleRef = useRef(0);

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

  const push = useCallback(
    (tone?: ToastTone) => {
      const preset =
        tone !== undefined
          ? (PRESETS.find((p) => p.tone === tone) ?? PRESETS[0])
          : PRESETS[cycleRef.current++ % PRESETS.length];
      idRef.current += 1;
      const next: ToastItem = { ...preset, id: idRef.current };
      setToasts((prev) => [next, ...prev].slice(0, 4));
    },
    [],
  );

  const clearAll = useCallback(() => setToasts([]), []);

  return (
    <section className="relative w-full bg-slate-50 px-4 py-20 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes ats_pulse_ring {
          0%, 100% { opacity: 0.55; transform: scale(1); }
          50% { opacity: 0.15; transform: scale(1.6); }
        }
        @media (prefers-reduced-motion: reduce) {
          .ats_pulse { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-xl">
        <div className="text-center">
          <span className="inline-flex items-center gap-2 rounded-full bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20">
            <span className="relative flex h-2 w-2">
              <span className="ats_pulse absolute inline-flex h-full w-full rounded-full bg-indigo-500" style={{ animation: "ats_pulse_ring 2.4s ease-in-out infinite" }} />
              <span className="relative inline-flex h-2 w-2 rounded-full bg-indigo-500" />
            </span>
            Toast notifications
          </span>
          <h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
            Slide-in toast with auto-dismiss
          </h2>
          <p className="mx-auto mt-3 max-w-md text-sm text-slate-600 dark:text-slate-400">
            Trigger a notification. Each toast slides in, counts down its own
            progress bar, and pauses whenever you hover or focus it.
          </p>
        </div>

        <div className="mt-8 flex flex-wrap items-center justify-center gap-2.5">
          {BUTTON_TONES.map((b) => (
            <button
              key={b.tone}
              type="button"
              onClick={() => push(b.tone)}
              className={[
                "rounded-lg px-3.5 py-2 text-sm font-medium shadow-sm transition",
                "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2",
                "active:scale-[0.97]",
                b.classes,
              ].join(" ")}
            >
              {b.label}
            </button>
          ))}
          <button
            type="button"
            onClick={clearAll}
            disabled={toasts.length === 0}
            className={[
              "rounded-lg px-3.5 py-2 text-sm font-medium transition",
              "border border-slate-300 text-slate-700 hover:bg-slate-100",
              "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-400",
              "disabled:cursor-not-allowed disabled:opacity-40",
              "dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800",
            ].join(" ")}
          >
            Clear all
          </button>
        </div>

        <p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
          {toasts.length === 0
            ? "No active notifications"
            : `${toasts.length} active ${toasts.length === 1 ? "toast" : "toasts"} · newest on top`}
        </p>
      </div>

      <div
        aria-live="polite"
        className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex flex-col items-center gap-3 px-4 sm:inset-x-auto sm:right-6 sm:bottom-6 sm:items-end"
      >
        <AnimatePresence mode="popLayout" initial={false}>
          {toasts.map((t) => (
            <Toast key={t.id} item={t} onDismiss={dismiss} reduce={reduce} />
          ))}
        </AnimatePresence>
      </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 →