Web InnoventixFreeCode

Stacked Toast

Original · free

stacked toast queue

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

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

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

type Position = "top-left" | "top-right" | "bottom-left" | "bottom-right";

interface Preset {
  title: string;
  description: string;
  action?: string;
}

interface ToastItem {
  id: string;
  type: ToastType;
  title: string;
  description: string;
  action?: string;
  duration: number;
}

const MAX_TOASTS = 4;
const COLLAPSED_VISIBLE = 3;
const PEEK = 16;
const GAP = 12;
const DURATION = 5400;
const FALLBACK_HEIGHT = 88;

const PRESETS: Record<ToastType, Preset[]> = {
  success: [
    {
      title: "Payment received",
      description: "Invoice INV-2043 for $2,400 was paid by Meridian Labs. A receipt is on its way.",
    },
    {
      title: "Deployment live",
      description: "main → production finished in 47s. 3 files changed, 0 errors.",
      action: "View log",
    },
    {
      title: "Backup complete",
      description: "Nightly snapshot saved to us-east-1 — 18.4 GB across 6 volumes.",
    },
  ],
  error: [
    {
      title: "Upload failed",
      description: "brand-assets.zip couldn't be uploaded. Check your connection and try again.",
      action: "Retry",
    },
    {
      title: "Payment declined",
      description: "The card ending 4291 was declined by the issuer. Try a different method.",
      action: "Update card",
    },
  ],
  warning: [
    {
      title: "Storage almost full",
      description: "You've used 92% of your 50 GB plan. Archive old projects to free up space.",
      action: "Manage",
    },
    {
      title: "Session expiring",
      description: "You'll be signed out in 5 minutes due to inactivity.",
      action: "Stay signed in",
    },
  ],
  info: [
    {
      title: "New comment from Priya",
      description: "“Can we ship the dark-mode variant this week?” on Checkout redesign.",
      action: "Reply",
    },
    {
      title: "3 teammates joined",
      description: "Ravi, Anna and Ken now have access to the Growth workspace.",
    },
  ],
};

const THEME: Record<
  ToastType,
  { chip: string; bar: string; label: string; dot: string }
> = {
  success: {
    chip: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400",
    bar: "bg-emerald-500",
    label: "Success",
    dot: "bg-emerald-500",
  },
  error: {
    chip: "bg-rose-50 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400",
    bar: "bg-rose-500",
    label: "Error",
    dot: "bg-rose-500",
  },
  warning: {
    chip: "bg-amber-50 text-amber-600 dark:bg-amber-500/15 dark:text-amber-400",
    bar: "bg-amber-500",
    label: "Warning",
    dot: "bg-amber-500",
  },
  info: {
    chip: "bg-sky-50 text-sky-600 dark:bg-sky-500/15 dark:text-sky-400",
    bar: "bg-sky-500",
    label: "Info",
    dot: "bg-sky-500",
  },
};

function TypeIcon({ type }: { type: ToastType }) {
  const common = {
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 2,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
    "aria-hidden": true,
    className: "h-5 w-5",
  };
  if (type === "success") {
    return (
      <svg {...common}>
        <path d="M21.5 12a9.5 9.5 0 1 1-4.2-7.9" />
        <path d="m8.5 12 2.7 2.7L22 4.4" />
      </svg>
    );
  }
  if (type === "error") {
    return (
      <svg {...common}>
        <circle cx="12" cy="12" r="9.25" />
        <path d="m15 9-6 6" />
        <path d="m9 9 6 6" />
      </svg>
    );
  }
  if (type === "warning") {
    return (
      <svg {...common}>
        <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 9.5v4" />
        <path d="M12 17.2h.01" />
      </svg>
    );
  }
  return (
    <svg {...common}>
      <circle cx="12" cy="12" r="9.25" />
      <path d="M12 16.5v-4.5" />
      <path d="M12 8h.01" />
    </svg>
  );
}

interface ToastCardProps {
  toast: ToastItem;
  index: number;
  total: number;
  expanded: boolean;
  paused: boolean;
  autoDismiss: boolean;
  position: Position;
  offsetBefore: number;
  reduced: boolean;
  onDismiss: (id: string) => void;
  onAction: (toast: ToastItem) => void;
  reportHeight: (id: string, h: number) => void;
}

function ToastCard({
  toast,
  index,
  total,
  expanded,
  paused,
  autoDismiss,
  position,
  offsetBefore,
  reduced,
  onDismiss,
  onAction,
  reportHeight,
}: ToastCardProps) {
  const cardRef = useRef<HTMLDivElement | null>(null);
  const remainingRef = useRef(toast.duration);
  const theme = THEME[toast.type];

  const isTop = position.startsWith("top");
  const isRight = position.endsWith("right");
  const dir = isTop ? 1 : -1;
  const enterX = isRight ? 34 : -34;

  useEffect(() => {
    const el = cardRef.current;
    if (!el) return;
    const send = () => reportHeight(toast.id, el.offsetHeight);
    const ro = new ResizeObserver(send);
    ro.observe(el);
    send();
    return () => ro.disconnect();
  }, [toast.id, reportHeight]);

  useEffect(() => {
    if (!autoDismiss || paused) return;
    const start = Date.now();
    const timer = window.setTimeout(() => onDismiss(toast.id), remainingRef.current);
    return () => {
      remainingRef.current = Math.max(0, remainingRef.current - (Date.now() - start));
      window.clearTimeout(timer);
    };
  }, [autoDismiss, paused, onDismiss, toast.id]);

  const clamped = Math.min(index, COLLAPSED_VISIBLE - 1);
  const target = expanded
    ? { x: 0, y: dir * offsetBefore, scale: 1, opacity: 1 }
    : {
        x: 0,
        y: dir * clamped * PEEK,
        scale: 1 - clamped * 0.05,
        opacity: index < COLLAPSED_VISIBLE ? 1 : 0,
      };

  const interactive = expanded || index === 0;
  const spring = reduced
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 380, damping: 38, mass: 0.9 };

  return (
    <motion.li
      className={`absolute inset-x-0 ${isTop ? "top-0" : "bottom-0"} list-none`}
      style={{ zIndex: total - index, pointerEvents: interactive ? "auto" : "none" }}
      initial={reduced ? false : { opacity: 0, x: enterX, scale: 0.85 }}
      animate={target}
      exit={reduced ? { opacity: 0 } : { opacity: 0, x: enterX, scale: 0.85, transition: { duration: 0.2 } }}
      transition={spring}
    >
      <div
        ref={cardRef}
        role={toast.type === "error" ? "alert" : "status"}
        className="relative overflow-hidden rounded-2xl border border-slate-200/90 bg-white shadow-lg shadow-slate-900/[0.06] dark:border-slate-700/70 dark:bg-slate-900 dark:shadow-black/40"
      >
        <div className="flex gap-3 p-3.5">
          <span
            className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${theme.chip}`}
            aria-hidden="true"
          >
            <TypeIcon type={toast.type} />
          </span>
          <div className="min-w-0 flex-1 pr-5">
            <p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
              {toast.title}
            </p>
            <p className="mt-0.5 line-clamp-2 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
              {toast.description}
            </p>
            {toast.action ? (
              <button
                type="button"
                onClick={() => onAction(toast)}
                className="mt-2 inline-flex items-center gap-1 rounded-md border border-slate-200 px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
              >
                {toast.action}
              </button>
            ) : null}
          </div>
        </div>
        <button
          type="button"
          onClick={() => onDismiss(toast.id)}
          aria-label={`Dismiss notification: ${toast.title}`}
          className="absolute right-2.5 top-2.5 grid h-6 w-6 place-items-center rounded-md text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-slate-800 dark:hover:text-slate-200"
        >
          <svg
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth={2}
            strokeLinecap="round"
            strokeLinejoin="round"
            aria-hidden="true"
            className="h-3.5 w-3.5"
          >
            <path d="M18 6 6 18" />
            <path d="m6 6 12 12" />
          </svg>
        </button>
        {autoDismiss ? (
          <span
            aria-hidden="true"
            className={`tqStack-progress absolute inset-x-0 bottom-0 h-[3px] origin-left ${theme.bar}`}
            style={{
              animation: `tqStack-shrink ${toast.duration}ms linear forwards`,
              animationPlayState: paused ? "paused" : "running",
            }}
          />
        ) : null}
      </div>
    </motion.li>
  );
}

export default function ToastStacked() {
  const reduced = useReducedMotion() ?? false;
  const [toasts, setToasts] = useState<ToastItem[]>([]);
  const [heights, setHeights] = useState<Record<string, number>>({});
  const [pinnedExpanded, setPinnedExpanded] = useState(false);
  const [hovered, setHovered] = useState(false);
  const [focusWithin, setFocusWithin] = useState(false);
  const [position, setPosition] = useState<Position>("bottom-right");
  const [autoDismiss, setAutoDismiss] = useState(true);

  const idRef = useRef(0);
  const counters = useRef<Record<ToastType, number>>({
    success: 0,
    error: 0,
    warning: 0,
    info: 0,
  });

  const nid = () => `tqs-${++idRef.current}`;

  const build = useCallback((type: ToastType): ToastItem => {
    const list = PRESETS[type];
    const preset = list[counters.current[type]++ % list.length];
    return { id: nid(), type, duration: DURATION, ...preset };
  }, []);

  const push = useCallback(
    (type: ToastType) => {
      setToasts((prev) => [build(type), ...prev].slice(0, MAX_TOASTS));
    },
    [build],
  );

  const burst = useCallback(() => {
    const order: ToastType[] = ["info", "warning", "success"];
    setToasts((prev) => {
      const next = order.map((t) => build(t));
      return [...next.reverse(), ...prev].slice(0, MAX_TOASTS);
    });
  }, [build]);

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

  const handleAction = useCallback((t: ToastItem) => {
    setToasts((prev) => {
      const filtered = prev.filter((x) => x.id !== t.id);
      const confirm: ToastItem = {
        id: `tqs-c-${Date.now()}`,
        type: "success",
        title: "On it",
        description: `“${t.action}” — we've queued that action for you.`,
        duration: DURATION,
      };
      return [confirm, ...filtered].slice(0, MAX_TOASTS);
    });
  }, []);

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

  const reportHeight = useCallback((id: string, h: number) => {
    setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));
  }, []);

  const expanded = pinnedExpanded || hovered || focusWithin;
  const count = toasts.length;

  const offsets = useMemo(() => {
    const out: number[] = [];
    let acc = 0;
    for (let i = 0; i < toasts.length; i++) {
      out[i] = acc;
      acc += (heights[toasts[i].id] ?? FALLBACK_HEIGHT) + GAP;
    }
    return out;
  }, [toasts, heights]);

  const isTop = position.startsWith("top");
  const isRight = position.endsWith("right");
  const anchorClass = `${isTop ? "top-4 sm:top-6" : "bottom-4 sm:bottom-6"} ${
    isRight ? "right-4 sm:right-6" : "left-4 sm:left-6"
  }`;

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

  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" },
  ];

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
      <div className="relative mx-auto max-w-5xl">
        <header className="mb-10 max-w-2xl">
          <span className="inline-flex items-center gap-2 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" />
            Toasts
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
            Stacked notification queue
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-500 dark:text-slate-400">
            Notifications collapse into a tidy stack and fan out on hover or focus. Each toast counts
            down, pauses while you read, and can be dismissed or acted on — all keyboard accessible.
          </p>
        </header>

        <div className="grid gap-6 lg:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
          {/* Control panel */}
          <div className="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
            <div className="flex items-center justify-between">
              <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
                Trigger notifications
              </h3>
              <span className="rounded-md bg-slate-100 px-2 py-0.5 text-xs font-medium tabular-nums text-slate-600 dark:bg-slate-800 dark:text-slate-300">
                {count}/{MAX_TOASTS}
              </span>
            </div>

            <div className="mt-4 grid grid-cols-2 gap-2">
              {triggers.map((t) => (
                <button
                  key={t.type}
                  type="button"
                  onClick={() => push(t.type)}
                  className="group flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:border-slate-300 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                >
                  <span className={`grid h-6 w-6 place-items-center rounded-md ${THEME[t.type].chip}`}>
                    <TypeIcon type={t.type} />
                  </span>
                  {t.label}
                </button>
              ))}
            </div>

            <button
              type="button"
              onClick={burst}
              className="mt-2 flex w-full items-center justify-center gap-2 rounded-lg bg-indigo-600 px-3 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
                className="h-4 w-4"
              >
                <path d="M13 2 4.5 12.5H11l-1 9.5 8.5-10.5H12l1-9.5Z" />
              </svg>
              Simulate a burst
            </button>

            <div className="my-5 h-px bg-slate-200 dark:bg-slate-800" />

            <fieldset>
              <legend className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
                Anchor position
              </legend>
              <div className="mt-2.5 grid grid-cols-2 gap-2">
                {positions.map((p) => (
                  <label key={p.value} className="cursor-pointer">
                    <input
                      type="radio"
                      name="tqStack-position"
                      value={p.value}
                      checked={position === p.value}
                      onChange={() => setPosition(p.value)}
                      className="peer sr-only"
                    />
                    <span className="flex items-center justify-center rounded-lg border border-slate-200 px-2 py-2 text-xs font-medium text-slate-600 transition-colors peer-checked:border-indigo-500 peer-checked:bg-indigo-50 peer-checked:text-indigo-700 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-300 dark:peer-checked:border-indigo-500 dark:peer-checked:bg-indigo-500/15 dark:peer-checked:text-indigo-300 dark:peer-focus-visible:ring-offset-slate-900">
                      {p.label}
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>

            <div className="mt-5 flex items-center justify-between gap-3">
              <span className="text-sm font-medium text-slate-700 dark:text-slate-200">
                Auto-dismiss
              </span>
              <button
                type="button"
                role="switch"
                aria-checked={autoDismiss}
                aria-label="Toggle auto-dismiss"
                onClick={() => setAutoDismiss((v) => !v)}
                className={`relative h-6 w-11 shrink-0 rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                  autoDismiss ? "bg-indigo-600" : "bg-slate-300 dark:bg-slate-700"
                }`}
              >
                <span
                  className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
                    autoDismiss ? "translate-x-5" : "translate-x-0"
                  }`}
                />
              </button>
            </div>

            <div className="mt-5 grid grid-cols-2 gap-2">
              <button
                type="button"
                onClick={() => setPinnedExpanded((v) => !v)}
                aria-pressed={pinnedExpanded}
                disabled={count === 0}
                className="rounded-lg border border-slate-200 px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-45 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
              >
                {pinnedExpanded ? "Collapse" : "Expand"}
              </button>
              <button
                type="button"
                onClick={clearAll}
                disabled={count === 0}
                className="rounded-lg border border-slate-200 px-3 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-45 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
              >
                Clear all
              </button>
            </div>
          </div>

          {/* Stage */}
          <div
            aria-label="Notification preview area"
            className="relative min-h-[440px] overflow-hidden rounded-2xl border border-slate-200 bg-slate-100 sm:min-h-[500px] dark:border-slate-800 dark:bg-slate-900/40"
            onMouseEnter={() => setHovered(true)}
            onMouseLeave={() => setHovered(false)}
            onFocusCapture={() => setFocusWithin(true)}
            onBlurCapture={(e) => {
              if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusWithin(false);
            }}
            onKeyDown={(e) => {
              if (e.key === "Escape" && toasts.length > 0) dismiss(toasts[0].id);
            }}
          >
            <div
              className="pointer-events-none absolute inset-0 bg-[radial-gradient(rgba(148,163,184,0.18)_1px,transparent_1px)] [background-size:20px_20px]"
              aria-hidden="true"
            />
            <span
              className="tqStack-orb pointer-events-none absolute -left-16 top-8 h-52 w-52 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15"
              aria-hidden="true"
            />
            <span
              className="tqStack-orb pointer-events-none absolute -bottom-16 right-4 h-56 w-56 rounded-full bg-emerald-400/20 blur-3xl dark:bg-emerald-500/10"
              aria-hidden="true"
              style={{ animationDelay: "-4s" }}
            />

            <div className="relative flex h-full items-center justify-center px-6 py-14">
              {count === 0 ? (
                <div className="max-w-xs text-center">
                  <span className="mx-auto grid h-12 w-12 place-items-center rounded-full border border-slate-200 bg-white text-slate-400 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-500">
                    <svg
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={1.75}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                      className="h-6 w-6"
                    >
                      <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
                      <path d="M10.3 21a1.9 1.9 0 0 0 3.4 0" />
                    </svg>
                  </span>
                  <p className="mt-4 text-sm font-medium text-slate-600 dark:text-slate-300">
                    Your queue is clear
                  </p>
                  <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
                    Trigger a notification from the panel to watch the stack build.
                  </p>
                </div>
              ) : (
                <span className="text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-600">
                  {expanded ? "Expanded" : "Hover or focus to expand"}
                </span>
              )}
            </div>

            <ol
              aria-label="Notifications"
              aria-live="polite"
              aria-relevant="additions text"
              className={`pointer-events-none absolute z-10 w-[min(340px,calc(100%-2rem))] ${anchorClass}`}
            >
              <AnimatePresence initial={false}>
                {toasts.map((t, i) => (
                  <ToastCard
                    key={t.id}
                    toast={t}
                    index={i}
                    total={toasts.length}
                    expanded={expanded}
                    paused={expanded}
                    autoDismiss={autoDismiss}
                    position={position}
                    offsetBefore={offsets[i] ?? 0}
                    reduced={reduced}
                    onDismiss={dismiss}
                    onAction={handleAction}
                    reportHeight={reportHeight}
                  />
                ))}
              </AnimatePresence>
            </ol>
          </div>
        </div>

        <p className="mt-4 text-xs text-slate-400 dark:text-slate-500">
          Newest on top · up to {MAX_TOASTS} kept · press Esc over the stage to dismiss the newest.
        </p>
      </div>

      <style>{`
        @keyframes tqStack-shrink {
          from { transform: scaleX(1); }
          to { transform: scaleX(0); }
        }
        @keyframes tqStack-drift {
          0%, 100% { transform: translate3d(0, 0, 0); }
          50% { transform: translate3d(8%, -6%, 0); }
        }
        .tqStack-orb {
          animation: tqStack-drift 14s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .tqStack-progress,
          .tqStack-orb {
            animation: none !important;
          }
        }
      `}</style>
    </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 →