Web InnoventixFreeCode

Basic Toast

Original · free

basic toast notifications

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

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

type ToastVariant = "success" | "error" | "info" | "warning";
type Position = "top-left" | "top-right" | "bottom-left" | "bottom-right";

interface ToastItem {
  id: number;
  variant: ToastVariant;
  title: string;
  description: string;
}

const DURATION = 5000;
const MAX_TOASTS = 4;

const MESSAGES: Record<ToastVariant, { title: string; description: string }[]> = {
  success: [
    { title: "Payment received", description: "Invoice #INV-2043 has been settled in full." },
    { title: "Deploy finished", description: "Build v2.8.0 is live on production with zero errors." },
    { title: "Saved to workspace", description: "Your changes synced across 3 devices a moment ago." },
  ],
  error: [
    { title: "Upload failed", description: "report-q3.pdf exceeded the 25 MB attachment limit." },
    { title: "Connection lost", description: "We couldn't reach the server. Retrying in 8 seconds." },
    { title: "Card declined", description: "The card ending 4291 was rejected by the issuer." },
  ],
  info: [
    { title: "Sync complete", description: "42 records were pulled from the CRM connector." },
    { title: "New mention", description: "Priya tagged you in the “Q3 roadmap” thread." },
    { title: "Update available", description: "Version 2.9.0 is ready — restart to install." },
  ],
  warning: [
    { title: "Storage almost full", description: "You've used 94% of your 10 GB workspace plan." },
    { title: "Session expiring", description: "You'll be signed out automatically in 5 minutes." },
    { title: "Unsaved changes", description: "Leaving this page now will discard 3 pending edits." },
  ],
};

const VARIANT_STYLES: Record<
  ToastVariant,
  { iconWrap: string; bar: string; trigger: string; ring: string }
> = {
  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:
      "bg-emerald-50 text-emerald-700 ring-emerald-200 hover:bg-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/25 dark:hover:bg-emerald-500/20",
    ring: "focus-visible:ring-emerald-500",
  },
  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:
      "bg-rose-50 text-rose-700 ring-rose-200 hover:bg-rose-100 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-500/25 dark:hover:bg-rose-500/20",
    ring: "focus-visible:ring-rose-500",
  },
  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:
      "bg-sky-50 text-sky-700 ring-sky-200 hover:bg-sky-100 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-500/25 dark:hover:bg-sky-500/20",
    ring: "focus-visible:ring-sky-500",
  },
  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:
      "bg-amber-50 text-amber-700 ring-amber-200 hover:bg-amber-100 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/25 dark:hover:bg-amber-500/20",
    ring: "focus-visible:ring-amber-500",
  },
};

const POSITIONS: { value: Position; label: string }[] = [
  { value: "top-left", label: "Top left" },
  { value: "top-right", label: "Top right" },
  { value: "bottom-left", label: "Bottom left" },
  { value: "bottom-right", label: "Bottom right" },
];

const POSITION_CLASSES: Record<Position, string> = {
  "top-left": "top-4 left-4 items-start flex-col-reverse",
  "top-right": "top-4 right-4 items-end flex-col-reverse",
  "bottom-left": "bottom-4 left-4 items-start flex-col",
  "bottom-right": "bottom-4 right-4 items-end flex-col",
};

function VariantIcon({ variant }: { variant: ToastVariant }): ReactNode {
  const common = { className: "h-5 w-5", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
  if (variant === "success") {
    return (
      <svg {...common} aria-hidden="true">
        <path d="M20 6 9 17l-5-5" />
      </svg>
    );
  }
  if (variant === "error") {
    return (
      <svg {...common} aria-hidden="true">
        <circle cx="12" cy="12" r="9" />
        <path d="m15 9-6 6M9 9l6 6" />
      </svg>
    );
  }
  if (variant === "warning") {
    return (
      <svg {...common} aria-hidden="true">
        <path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
        <path d="M12 9v4M12 17h.01" />
      </svg>
    );
  }
  return (
    <svg {...common} aria-hidden="true">
      <circle cx="12" cy="12" r="9" />
      <path d="M12 11v5M12 8h.01" />
    </svg>
  );
}

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

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

  const start = useCallback(() => {
    startRef.current = Date.now();
    timerRef.current = setTimeout(() => onDismiss(toast.id), remainingRef.current);
  }, [onDismiss, toast.id]);

  useEffect(() => {
    start();
    return clear;
  }, [start, clear]);

  const pause = useCallback(() => {
    setPaused((prev) => {
      if (prev) return prev;
      clear();
      remainingRef.current = Math.max(0, remainingRef.current - (Date.now() - startRef.current));
      return true;
    });
  }, [clear]);

  const resume = useCallback(() => {
    setPaused((prev) => {
      if (!prev) return prev;
      start();
      return false;
    });
  }, [start]);

  const onBlur = (event: FocusEvent<HTMLLIElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget as Node | null)) resume();
  };

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

  const styles = VARIANT_STYLES[toast.variant];
  const assertive = toast.variant === "error" || toast.variant === "warning";

  return (
    <motion.li
      layout={!reduced}
      initial={reduced ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.96 }}
      animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
      exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94, transition: { duration: 0.18 } }}
      transition={{ duration: reduced ? 0.15 : 0.32, ease: [0.22, 1, 0.36, 1] }}
      role={assertive ? "alert" : "status"}
      aria-live={assertive ? "assertive" : "polite"}
      aria-atomic="true"
      onMouseEnter={pause}
      onMouseLeave={resume}
      onFocus={pause}
      onBlur={onBlur}
      onKeyDown={onKeyDown}
      className="pointer-events-auto relative w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30"
    >
      <div className="flex items-start gap-3 p-4">
        <span
          className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${styles.iconWrap}`}
        >
          <VariantIcon variant={toast.variant} />
        </span>
        <div className="min-w-0 flex-1">
          <p className="text-sm font-semibold text-slate-900 dark:text-white">{toast.title}</p>
          <p className="mt-0.5 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            {toast.description}
          </p>
        </div>
        <button
          type="button"
          onClick={() => onDismiss(toast.id)}
          aria-label={`Dismiss notification: ${toast.title}`}
          className="-mr-1 -mt-1 shrink-0 rounded-md p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:hover:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
        >
          <svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true">
            <path d="M18 6 6 18M6 6l12 12" />
          </svg>
        </button>
      </div>
      {!reduced && (
        <div className="absolute inset-x-0 bottom-0 h-1 bg-slate-100 dark:bg-slate-800" aria-hidden="true">
          <div
            className={`tb-progress-bar h-full w-full ${styles.bar}`}
            style={{ animationDuration: `${DURATION}ms`, animationPlayState: paused ? "paused" : "running" }}
          />
        </div>
      )}
    </motion.li>
  );
}

export default function ToastBasic() {
  const reduced = useReducedMotion() ?? false;
  const [toasts, setToasts] = useState<ToastItem[]>([]);
  const [position, setPosition] = useState<Position>("bottom-right");
  const idRef = useRef(0);
  const cycleRef = useRef<Record<ToastVariant, number>>({ success: 0, error: 0, info: 0, warning: 0 });

  const push = useCallback((variant: ToastVariant) => {
    const pool = MESSAGES[variant];
    const index = cycleRef.current[variant] % pool.length;
    cycleRef.current[variant] += 1;
    const message = pool[index];
    const id = (idRef.current += 1);
    setToasts((prev) => {
      const next = [...prev, { id, variant, ...message }];
      return next.slice(Math.max(0, next.length - MAX_TOASTS));
    });
  }, []);

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

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

  const triggers: { variant: ToastVariant; label: string }[] = [
    { variant: "success", label: "Success" },
    { variant: "error", label: "Error" },
    { variant: "info", label: "Info" },
    { variant: "warning", label: "Warning" },
  ];

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

      <div
        className="pointer-events-none absolute z-10 h-40 w-40 rounded-full bg-indigo-500/10 blur-3xl dark:bg-indigo-500/15"
        style={{ top: "-2rem", right: "-2rem" }}
        aria-hidden="true"
      />

      <div className="relative mx-auto max-w-2xl">
        <div className="text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Feedback &amp; notifications
          </p>
          <h2 className="mt-3 text-balance text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Toast notifications
          </h2>
          <p className="mx-auto mt-4 max-w-lg text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Fire a stackable, auto-dismissing toast. Hover or focus one to pause its
            timer, press <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 text-xs font-medium text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">Esc</kbd> to
            dismiss it, and pick where new toasts appear.
          </p>
        </div>

        <div className="mt-10 rounded-2xl border border-slate-200 bg-white/70 p-5 shadow-sm backdrop-blur sm:p-6 dark:border-slate-800 dark:bg-slate-900/60">
          <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
            {triggers.map(({ variant, label }) => {
              const styles = VARIANT_STYLES[variant];
              return (
                <button
                  key={variant}
                  type="button"
                  onClick={() => push(variant)}
                  className={`inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-semibold ring-1 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${styles.trigger} ${styles.ring}`}
                >
                  <VariantIcon variant={variant} />
                  {label}
                </button>
              );
            })}
          </div>

          <div className="mt-5 flex flex-col gap-4 border-t border-slate-200 pt-5 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800">
            <fieldset>
              <legend className="mb-2 text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
                Position
              </legend>
              <div className="inline-flex flex-wrap gap-1 rounded-lg bg-slate-100 p-1 dark:bg-slate-800">
                {POSITIONS.map((option) => (
                  <label key={option.value} className="cursor-pointer">
                    <input
                      type="radio"
                      name="tb-position"
                      value={option.value}
                      checked={position === option.value}
                      onChange={() => setPosition(option.value)}
                      className="peer sr-only"
                    />
                    <span className="block rounded-md px-3 py-1.5 text-xs font-medium text-slate-600 ring-1 ring-transparent transition peer-checked:bg-white peer-checked:text-slate-900 peer-checked:shadow-sm peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 dark:text-slate-400 dark:peer-checked:bg-slate-700 dark:peer-checked:text-white">
                      {option.label}
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>

            <div className="flex items-center gap-3">
              <span aria-live="polite" className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
                {toasts.length} active
              </span>
              <button
                type="button"
                onClick={clearAll}
                disabled={toasts.length === 0}
                className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
              >
                Clear all
              </button>
            </div>
          </div>
        </div>
      </div>

      <ol
        role="region"
        aria-label="Notifications"
        className={`pointer-events-none fixed z-30 flex w-[min(22rem,calc(100vw-2rem))] gap-3 ${POSITION_CLASSES[position]}`}
      >
        <AnimatePresence mode="popLayout">
          {toasts.map((toast) => (
            <Toast key={toast.id} toast={toast} onDismiss={remove} reduced={reduced} />
          ))}
        </AnimatePresence>
      </ol>
    </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 →