Web InnoventixFreeCode

Glass Toast

Original · free

glassmorphic toasts

byWeb InnoventixReact + Tailwind
toastglasstoasts
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-glass.json
toast-glass.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 ToastPosition = "top" | "bottom";

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

const MAX_STACK = 4;

const DURATIONS: Record<ToastVariant, number> = {
  success: 5000,
  info: 5500,
  warning: 7000,
  error: 8000,
};

const MESSAGES: Record<
  ToastVariant,
  ReadonlyArray<{ title: string; description: string; action?: string }>
> = {
  success: [
    {
      title: "Deployment is live",
      description: "webinnoventix.com is now serving from 14 edge regions.",
      action: "View build",
    },
    {
      title: "Payment received",
      description: "Invoice INV-2043 for $480 was settled by Northwind Studio.",
      action: "Open receipt",
    },
  ],
  error: [
    {
      title: "Upload failed",
      description: "hero-v3.mp4 is 118 MB — over the 100 MB Starter limit.",
      action: "Retry",
    },
    {
      title: "Card was declined",
      description: "The card ending 4291 was rejected. Update billing to keep Pro.",
      action: "Fix billing",
    },
  ],
  info: [
    {
      title: "New comment from Priya",
      description: "She left a note on line 24 of the Q3 roadmap draft.",
      action: "Open thread",
    },
    {
      title: "Import complete",
      description: "1,204 rows synced from the Notion project database.",
    },
  ],
  warning: [
    {
      title: "Storage almost full",
      description: "You've used 92% of your 50 GB plan. Archive old exports soon.",
      action: "Manage files",
    },
    {
      title: "Session expiring",
      description: "You'll be signed out in 5 minutes unless you stay active.",
      action: "Stay signed in",
    },
  ],
};

const VARIANT_STYLE: Record<
  ToastVariant,
  { role: "status" | "alert"; bar: string; chip: string; icon: string; dot: string }
> = {
  success: {
    role: "status",
    bar: "bg-emerald-500",
    chip: "bg-emerald-500/15 ring-emerald-500/30",
    icon: "text-emerald-600 dark:text-emerald-400",
    dot: "bg-emerald-500",
  },
  error: {
    role: "alert",
    bar: "bg-rose-500",
    chip: "bg-rose-500/15 ring-rose-500/30",
    icon: "text-rose-600 dark:text-rose-400",
    dot: "bg-rose-500",
  },
  info: {
    role: "status",
    bar: "bg-sky-500",
    chip: "bg-sky-500/15 ring-sky-500/30",
    icon: "text-sky-600 dark:text-sky-400",
    dot: "bg-sky-500",
  },
  warning: {
    role: "alert",
    bar: "bg-amber-500",
    chip: "bg-amber-500/15 ring-amber-500/30",
    icon: "text-amber-600 dark:text-amber-400",
    dot: "bg-amber-500",
  },
};

const LABELS: Record<ToastVariant, string> = {
  success: "Success",
  error: "Error",
  info: "Info",
  warning: "Warning",
};

function IconCheck({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path
        d="M20 6 9 17l-5-5"
        stroke="currentColor"
        strokeWidth="2.2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function IconAlert({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path
        d="M12 8.5v5m0 3.5h.01M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.7 3.86a2 2 0 0 0-3.42 0Z"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function IconInfo({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <circle cx="12" cy="12" r="9.25" stroke="currentColor" strokeWidth="2" />
      <path
        d="M12 11v5m0-8.5h.01"
        stroke="currentColor"
        strokeWidth="2.2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function IconXCircle({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <circle cx="12" cy="12" r="9.25" stroke="currentColor" strokeWidth="2" />
      <path
        d="m15 9-6 6m0-6 6 6"
        stroke="currentColor"
        strokeWidth="2.2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function IconClose({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path
        d="m6 6 12 12M18 6 6 18"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

const ICONS: Record<ToastVariant, (p: { className?: string }) => ReactNode> = {
  success: IconCheck,
  error: IconXCircle,
  info: IconInfo,
  warning: IconAlert,
};

function ToastCard({
  toast,
  reduced,
  onDismiss,
}: {
  toast: ToastItem;
  reduced: boolean;
  onDismiss: (id: number) => void;
}) {
  const style = VARIANT_STYLE[toast.variant];
  const Icon = ICONS[toast.variant];
  const duration = DURATIONS[toast.variant];

  const barRef = useRef<HTMLDivElement>(null);
  const elapsedRef = useRef(0);
  const pausedRef = useRef(false);
  const hoverRef = useRef(false);
  const focusRef = useRef(false);
  const [paused, setPaused] = useState(false);

  const sync = useCallback(() => {
    const next = hoverRef.current || focusRef.current;
    pausedRef.current = next;
    setPaused(next);
  }, []);

  useEffect(() => {
    let raf = 0;
    let last = performance.now();
    const tick = (now: number) => {
      const dt = now - last;
      last = now;
      if (!pausedRef.current) elapsedRef.current += dt;
      const remain = Math.max(0, duration - elapsedRef.current);
      if (barRef.current) {
        barRef.current.style.width = `${(remain / duration) * 100}%`;
      }
      if (remain <= 0) {
        onDismiss(toast.id);
        return;
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [toast.id, duration, onDismiss]);

  const handleFocus = () => {
    focusRef.current = true;
    sync();
  };
  const handleBlur = (e: FocusEvent<HTMLDivElement>) => {
    if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
      focusRef.current = false;
      sync();
    }
  };
  const handleEnter = () => {
    hoverRef.current = true;
    sync();
  };
  const handleLeave = () => {
    hoverRef.current = false;
    sync();
  };
  const handleKey = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === "Escape") {
      e.preventDefault();
      onDismiss(toast.id);
    }
  };

  const enter = reduced
    ? { opacity: 0 }
    : { opacity: 0, x: 44, scale: 0.94, filter: "blur(6px)" };
  const shown = reduced
    ? { opacity: 1 }
    : { opacity: 1, x: 0, scale: 1, filter: "blur(0px)" };
  const leave = reduced
    ? { opacity: 0 }
    : { opacity: 0, x: 44, scale: 0.94, filter: "blur(6px)" };
  const transition = reduced
    ? { duration: 0.15 }
    : ({ type: "spring", stiffness: 460, damping: 34, mass: 0.85 } as const);

  return (
    <motion.li
      layout={!reduced}
      initial={enter}
      animate={shown}
      exit={leave}
      transition={transition}
      className="pointer-events-auto list-none"
    >
      <div
        role={style.role}
        aria-atomic="true"
        tabIndex={0}
        onMouseEnter={handleEnter}
        onMouseLeave={handleLeave}
        onFocus={handleFocus}
        onBlur={handleBlur}
        onKeyDown={handleKey}
        className="tg-card group relative overflow-hidden rounded-2xl border border-white/60 bg-white/55 shadow-[0_18px_50px_-18px_rgba(15,23,42,0.5)] ring-1 ring-black/5 backdrop-blur-xl transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white/40 dark:border-white/10 dark:bg-slate-900/55 dark:ring-white/10 dark:focus-visible:ring-offset-slate-900/40"
      >
        <span aria-hidden="true" className="tg-sheen" />
        <div className="flex items-start gap-3 p-4">
          <span
            className={`grid h-9 w-9 shrink-0 place-items-center rounded-xl ring-1 ${style.chip} ${style.icon}`}
          >
            <Icon className="h-5 w-5" />
          </span>
          <div className="min-w-0 flex-1">
            <p className="text-sm font-semibold text-slate-900 dark:text-slate-50">
              {toast.title}
            </p>
            <p className="mt-0.5 text-sm leading-snug text-slate-600 dark:text-slate-300">
              {toast.description}
            </p>
            {toast.action ? (
              <div className="mt-2.5">
                <button
                  type="button"
                  onClick={() => onDismiss(toast.id)}
                  className="rounded-lg px-2.5 py-1 text-xs font-semibold text-slate-700 ring-1 ring-inset ring-black/10 transition-colors hover:bg-black/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-100 dark:ring-white/15 dark:hover:bg-white/10"
                >
                  {toast.action}
                </button>
              </div>
            ) : null}
          </div>
          <button
            type="button"
            aria-label={`Dismiss notification: ${toast.title}`}
            onClick={() => onDismiss(toast.id)}
            className="-mr-1 -mt-1 shrink-0 rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-black/[0.05] hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-white/10 dark:hover:text-slate-50"
          >
            <IconClose className="h-4 w-4" />
          </button>
        </div>
        <div className="h-1 w-full bg-black/5 dark:bg-white/10">
          <div
            ref={barRef}
            className={`h-full ${style.bar}`}
            style={{ width: "100%" }}
          />
        </div>
        <span className="sr-only" aria-live="off">
          {paused ? "Timer paused" : ""}
        </span>
      </div>
    </motion.li>
  );
}

function PositionToggle({
  value,
  onChange,
}: {
  value: ToastPosition;
  onChange: (p: ToastPosition) => void;
}) {
  const options: ReadonlyArray<{ id: ToastPosition; label: string }> = [
    { id: "top", label: "Top" },
    { id: "bottom", label: "Bottom" },
  ];
  const refs = useRef<Record<ToastPosition, HTMLButtonElement | null>>({
    top: null,
    bottom: null,
  });

  const move = (next: ToastPosition) => {
    onChange(next);
    refs.current[next]?.focus();
  };

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (
      e.key === "ArrowRight" ||
      e.key === "ArrowLeft" ||
      e.key === "ArrowUp" ||
      e.key === "ArrowDown"
    ) {
      e.preventDefault();
      move(value === "top" ? "bottom" : "top");
    }
  };

  return (
    <div
      role="radiogroup"
      aria-label="Toast position"
      onKeyDown={onKeyDown}
      className="inline-flex rounded-xl border border-slate-300/70 bg-white/60 p-1 backdrop-blur dark:border-white/10 dark:bg-slate-900/50"
    >
      {options.map((o) => {
        const active = value === o.id;
        return (
          <button
            key={o.id}
            ref={(el) => {
              refs.current[o.id] = el;
            }}
            type="button"
            role="radio"
            aria-checked={active}
            tabIndex={active ? 0 : -1}
            onClick={() => onChange(o.id)}
            className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
              active
                ? "bg-slate-900 text-white shadow-sm dark:bg-white dark:text-slate-900"
                : "text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-white"
            }`}
          >
            {o.label}
          </button>
        );
      })}
    </div>
  );
}

export default function ToastGlass() {
  const reduced = !!useReducedMotion();
  const [toasts, setToasts] = useState<ToastItem[]>([]);
  const [position, setPosition] = useState<ToastPosition>("bottom");
  const idRef = useRef(1);

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

  const fire = useCallback((variant: ToastVariant) => {
    const pool = MESSAGES[variant];
    const pick = pool[Math.floor(Math.random() * pool.length)];
    const id = idRef.current++;
    setToasts((prev) => {
      const next = [...prev, { id, variant, ...pick }];
      return next.slice(-MAX_STACK);
    });
  }, []);

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

  const triggers: ReadonlyArray<ToastVariant> = [
    "success",
    "error",
    "info",
    "warning",
  ];

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-100 via-white to-slate-100 px-6 py-20 text-slate-900 sm:py-24 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes tg_float {
          0%, 100% { transform: translate3d(0, 0, 0); }
          50% { transform: translate3d(0, -30px, 0); }
        }
        @keyframes tg_drift {
          0%, 100% { transform: translate3d(0, 0, 0); }
          50% { transform: translate3d(26px, 18px, 0); }
        }
        @keyframes tg_sheen {
          0% { background-position: -220% 0; }
          100% { background-position: 220% 0; }
        }
        .tg-b1 { animation: tg_float 9s ease-in-out infinite; }
        .tg-b2 { animation: tg_drift 11s ease-in-out infinite; }
        .tg-b3 { animation: tg_float 13s ease-in-out infinite; }
        .tg-sheen {
          position: absolute;
          top: 0;
          left: 0;
          right: 0;
          height: 1px;
          background: linear-gradient(90deg, transparent, rgba(255,255,255,0.95), transparent);
          background-size: 220% 100%;
          animation: tg_sheen 3.6s linear infinite;
          pointer-events: none;
        }
        @media (prefers-reduced-motion: reduce) {
          .tg-b1, .tg-b2, .tg-b3, .tg-sheen { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 overflow-hidden"
      >
        <div className="tg-b1 absolute -left-16 -top-24 h-72 w-72 rounded-full bg-indigo-400/30 blur-3xl dark:bg-indigo-500/20" />
        <div className="tg-b2 absolute -right-20 top-1/3 h-80 w-80 rounded-full bg-violet-400/25 blur-3xl dark:bg-violet-600/20" />
        <div className="tg-b3 absolute -bottom-24 left-1/4 h-72 w-72 rounded-full bg-sky-300/25 blur-3xl dark:bg-sky-500/15" />
      </div>

      <div className="relative mx-auto flex min-h-[560px] w-full max-w-3xl flex-col items-center text-center">
        <span className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/60 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 backdrop-blur dark:border-white/10 dark:bg-slate-900/50 dark:text-indigo-300">
          <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
          Notifications
        </span>

        <h2 className="mt-6 text-4xl font-semibold tracking-tight sm:text-5xl">
          Glassmorphic toasts
        </h2>
        <p className="mt-4 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-300">
          Frosted, stackable alerts that pause when you hover or focus them,
          dismiss with a click or the Escape key, and announce themselves to
          screen readers as they arrive.
        </p>

        <div className="mt-9 flex flex-wrap items-center justify-center gap-2.5">
          {triggers.map((v) => {
            const s = VARIANT_STYLE[v];
            return (
              <button
                key={v}
                type="button"
                onClick={() => fire(v)}
                className="group inline-flex items-center gap-2 rounded-xl border border-slate-300/70 bg-white/70 px-4 py-2.5 text-sm font-semibold text-slate-800 shadow-sm backdrop-blur transition-all hover:-translate-y-0.5 hover:bg-white hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 active:translate-y-0 dark:border-white/10 dark:bg-slate-900/60 dark:text-slate-100 dark:hover:bg-slate-800/80"
              >
                <span className={`h-2 w-2 rounded-full ${s.dot}`} />
                {LABELS[v]}
              </button>
            );
          })}
        </div>

        <div className="mt-6 flex flex-wrap items-center justify-center gap-4">
          <PositionToggle value={position} onChange={setPosition} />
          <button
            type="button"
            onClick={clearAll}
            disabled={toasts.length === 0}
            className="rounded-xl px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-40 dark:text-slate-300 dark:hover:text-white"
          >
            Clear all{toasts.length ? ` (${toasts.length})` : ""}
          </button>
        </div>

        <p className="mt-6 text-xs text-slate-500 dark:text-slate-400">
          Hover to pause the timer · Focus a toast and press Esc to close ·
          Arrow keys switch the corner
        </p>
      </div>

      <ul
        aria-label="Notifications"
        className={`pointer-events-none absolute z-10 flex w-[min(92vw,22rem)] gap-3 ${
          position === "top"
            ? "right-4 top-4 flex-col-reverse sm:right-8 sm:top-8"
            : "bottom-4 right-4 flex-col sm:bottom-8 sm:right-8"
        }`}
      >
        <AnimatePresence mode="popLayout" initial={false}>
          {toasts.map((toast) => (
            <ToastCard
              key={toast.id}
              toast={toast}
              reduced={reduced}
              onDismiss={dismiss}
            />
          ))}
        </AnimatePresence>
      </ul>
    </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 →