Web InnoventixFreeCode

Cookie Modal

Original · free

cookie preferences modal

byWeb InnoventixReact + Tailwind
modalcookiemodals
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/modal-cookie.json
modal-cookie.tsx
"use client";

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

type CategoryId = "necessary" | "functional" | "analytics" | "marketing";

type Preferences = Record<CategoryId, boolean>;

interface Category {
  id: CategoryId;
  name: string;
  required?: boolean;
  summary: string;
  providers: string[];
}

const CATEGORIES: Category[] = [
  {
    id: "necessary",
    name: "Strictly necessary",
    required: true,
    summary:
      "Keep you signed in, balance server load, and remember these very cookie choices. The site breaks without them, so they stay on.",
    providers: ["Session", "CSRF token", "Load balancer"],
  },
  {
    id: "functional",
    name: "Functional",
    summary:
      "Remember the small stuff — language, region, dark mode, and which panels you collapsed — so the site feels the way you left it.",
    providers: ["Preferences", "Theme", "Locale"],
  },
  {
    id: "analytics",
    name: "Analytics",
    summary:
      "Privacy-friendly, aggregate stats via Plausible that show which articles get read and where readers lose interest. No cross-site tracking.",
    providers: ["Plausible", "Page timing"],
  },
  {
    id: "marketing",
    name: "Marketing",
    summary:
      "Measure how our campaigns perform and tailor what you see off-site. We never sell your data to third parties.",
    providers: ["Conversion pixel", "Retargeting"],
  },
];

const ALL_ON: Preferences = { necessary: true, functional: true, analytics: true, marketing: true };
const ALL_OFF: Preferences = { necessary: true, functional: false, analytics: false, marketing: false };

function getFocusable(root: HTMLElement): HTMLElement[] {
  const selector =
    'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
  return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
    (el) => el.getAttribute("aria-hidden") !== "true" && el.offsetParent !== null,
  );
}

function CookieIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path
        d="M12 3a9 9 0 1 0 9 9 3 3 0 0 1-3-3 3 3 0 0 1-3-3 3 3 0 0 1-3-3Z"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
      <circle cx="9" cy="11.5" r="1.1" fill="currentColor" />
      <circle cx="13" cy="15" r="1.1" fill="currentColor" />
      <circle cx="15.5" cy="11" r="1" fill="currentColor" />
      <circle cx="8.5" cy="15.5" r="0.9" fill="currentColor" />
    </svg>
  );
}

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

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      <path d="M5 12.5l4.5 4.5L19 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

interface ToggleSwitchProps {
  checked: boolean;
  onChange: (next: boolean) => void;
  disabled?: boolean;
  label: string;
  describedBy?: string;
}

function ToggleSwitch({ checked, onChange, disabled = false, label, describedBy }: ToggleSwitchProps) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      aria-label={label}
      aria-describedby={describedBy}
      disabled={disabled}
      onClick={() => onChange(!checked)}
      className={[
        "relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-200 motion-reduce:transition-none",
        "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",
        checked ? "bg-emerald-500" : "bg-slate-300 dark:bg-slate-600",
        disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer",
      ].join(" ")}
    >
      <span
        className={[
          "inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform duration-200 motion-reduce:transition-none",
          checked ? "translate-x-5" : "translate-x-0.5",
        ].join(" ")}
      />
    </button>
  );
}

export default function CookiePreferencesModal() {
  const reduce = useReducedMotion();

  const [open, setOpen] = useState(false);
  const [saved, setSaved] = useState<Preferences>(ALL_OFF);
  const [draft, setDraft] = useState<Preferences>(ALL_OFF);
  const [status, setStatus] = useState<string>("");

  const panelRef = useRef<HTMLDivElement | null>(null);
  const previouslyFocused = useRef<HTMLElement | null>(null);

  const titleId = useId();
  const introId = useId();

  const activeCount = Object.values(saved).filter(Boolean).length;

  function openModal() {
    setDraft(saved);
    setOpen(true);
  }

  function commit(next: Preferences) {
    const settled: Preferences = { ...next, necessary: true };
    setSaved(settled);
    const count = Object.values(settled).filter(Boolean).length;
    setStatus(`Preferences saved — ${count} of ${CATEGORIES.length} categories active.`);
    setOpen(false);
  }

  function setDraftFor(id: CategoryId, value: boolean) {
    setDraft((prev) => ({ ...prev, [id]: value, necessary: true }));
  }

  useEffect(() => {
    if (!open) return;

    previouslyFocused.current = document.activeElement as HTMLElement | null;
    const panel = panelRef.current;
    if (!panel) return;

    const focusables = getFocusable(panel);
    (focusables[0] ?? panel).focus();

    function onKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") {
        event.preventDefault();
        setOpen(false);
        return;
      }
      if (event.key !== "Tab" || !panel) return;

      const items = getFocusable(panel);
      if (items.length === 0) {
        event.preventDefault();
        panel.focus();
        return;
      }
      const first = items[0];
      const last = items[items.length - 1];
      const active = document.activeElement;

      if (event.shiftKey) {
        if (active === first || !panel.contains(active)) {
          event.preventDefault();
          last.focus();
        }
      } else if (active === last || !panel.contains(active)) {
        event.preventDefault();
        first.focus();
      }
    }

    document.addEventListener("keydown", onKeyDown);
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";

    return () => {
      document.removeEventListener("keydown", onKeyDown);
      document.body.style.overflow = previousOverflow;
      previouslyFocused.current?.focus?.();
    };
  }, [open]);

  const overlayVariants = { hidden: { opacity: 0 }, visible: { opacity: 1 }, exit: { opacity: 0 } };
  const panelVariants = reduce
    ? { hidden: { opacity: 0 }, visible: { opacity: 1 }, exit: { opacity: 0 } }
    : {
        hidden: { opacity: 0, y: 24, scale: 0.97 },
        visible: { opacity: 1, y: 0, scale: 1 },
        exit: { opacity: 0, y: 12, scale: 0.98 },
      };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 sm:px-6 sm:py-28 dark:bg-slate-950">
      <style>{`
        @keyframes cookieprefs-pulse { 0%, 100% { opacity: .35; transform: scale(1); } 50% { opacity: 1; transform: scale(1.6); } }
        @keyframes cookieprefs-sheen { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
        .cookieprefs-pulse { animation: cookieprefs-pulse 2.4s ease-in-out infinite; }
        .cookieprefs-sheen { animation: cookieprefs-sheen 6s linear infinite; }
        @media (prefers-reduced-motion: reduce) {
          .cookieprefs-pulse, .cookieprefs-sheen { animation: none !important; }
        }
      `}</style>

      {/* Ambient background */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0">
        <div className="absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
        <div className="absolute -bottom-32 right-0 h-80 w-80 rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-700/20" />
        <div className="absolute inset-0 opacity-[0.35] [background-image:linear-gradient(to_right,rgba(100,116,139,0.12)_1px,transparent_1px),linear-gradient(to_bottom,rgba(100,116,139,0.12)_1px,transparent_1px)] [background-size:38px_38px] [mask-image:radial-gradient(ellipse_at_center,black,transparent_75%)]" />
      </div>

      <div className="relative mx-auto max-w-3xl">
        <div className="overflow-hidden rounded-3xl border border-slate-200 bg-white/80 shadow-xl shadow-slate-900/5 backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-black/30">
          <div className="p-8 sm:p-10">
            <span className="cookieprefs-sheen inline-flex items-center gap-2 bg-gradient-to-r from-indigo-500 via-violet-400 to-indigo-500 bg-[length:200%_100%] bg-clip-text text-xs font-semibold uppercase tracking-[0.2em] text-transparent">
              <CookieIcon className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />
              Privacy center
            </span>

            <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
              You decide what we remember.
            </h2>
            <p className="mt-4 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-300">
              Innoventix uses a small set of cookies to run reliably, understand what&apos;s useful, and improve. Necessary
              cookies are always on. Everything else is your call — change it any time.
            </p>

            <div className="mt-6 flex flex-wrap items-center gap-2">
              {CATEGORIES.map((cat) => {
                const on = saved[cat.id];
                return (
                  <span
                    key={cat.id}
                    className={[
                      "inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium",
                      on
                        ? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300"
                        : "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/50 dark:text-slate-400",
                    ].join(" ")}
                  >
                    <span
                      className={[
                        "h-1.5 w-1.5 rounded-full",
                        on ? "cookieprefs-pulse bg-emerald-500" : "bg-slate-400 dark:bg-slate-500",
                      ].join(" ")}
                    />
                    {cat.name}
                  </span>
                );
              })}
            </div>

            <div className="mt-8 flex flex-col gap-3 sm:flex-row">
              <button
                type="button"
                onClick={openModal}
                className="inline-flex items-center justify-center gap-2 rounded-xl bg-slate-900 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
              >
                <CookieIcon className="h-4 w-4" />
                Manage preferences
              </button>
              <button
                type="button"
                onClick={() => commit(ALL_ON)}
                className="inline-flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-100 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-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
              >
                Accept all
              </button>
            </div>

            <p role="status" aria-live="polite" className="mt-5 min-h-[1.25rem] text-sm text-slate-500 dark:text-slate-400">
              {status || `${activeCount} of ${CATEGORIES.length} categories active · Last choice applied on this device.`}
            </p>
          </div>
        </div>
      </div>

      <AnimatePresence>
        {open && (
          <motion.div
            key="cookie-overlay"
            className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/60 p-4 backdrop-blur-sm sm:items-center"
            variants={overlayVariants}
            initial="hidden"
            animate="visible"
            exit="exit"
            transition={{ duration: 0.18 }}
            onClick={(event) => {
              if (event.target === event.currentTarget) setOpen(false);
            }}
          >
            <motion.div
              ref={panelRef}
              role="dialog"
              aria-modal="true"
              aria-labelledby={titleId}
              aria-describedby={introId}
              tabIndex={-1}
              variants={panelVariants}
              initial="hidden"
              animate="visible"
              exit="exit"
              transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
              className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl focus:outline-none dark:border-slate-800 dark:bg-slate-900"
            >
              {/* Header */}
              <div className="flex items-start gap-4 border-b border-slate-200 p-6 dark:border-slate-800">
                <span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400">
                  <CookieIcon className="h-6 w-6" />
                </span>
                <div className="min-w-0 flex-1">
                  <h3 id={titleId} className="text-lg font-semibold text-slate-900 dark:text-white">
                    Cookie preferences
                  </h3>
                  <p id={introId} className="mt-1 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
                    Toggle each category on or off. Changes apply when you save.
                  </p>
                </div>
                <button
                  type="button"
                  onClick={() => setOpen(false)}
                  aria-label="Close cookie preferences"
                  className="-mr-1 -mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
                >
                  <CloseIcon className="h-5 w-5" />
                </button>
              </div>

              {/* Body */}
              <div className="flex-1 space-y-3 overflow-y-auto p-6">
                {CATEGORIES.map((cat) => {
                  const descId = `${titleId}-${cat.id}`;
                  const checked = cat.required ? true : draft[cat.id];
                  return (
                    <div
                      key={cat.id}
                      className="rounded-xl border border-slate-200 bg-slate-50/60 p-4 dark:border-slate-800 dark:bg-slate-800/40"
                    >
                      <div className="flex items-start justify-between gap-4">
                        <div className="min-w-0">
                          <div className="flex items-center gap-2">
                            <h4 className="text-sm font-semibold text-slate-900 dark:text-white">{cat.name}</h4>
                            {cat.required && (
                              <span className="inline-flex items-center gap-1 rounded-full bg-slate-200 px-2 py-0.5 text-[11px] font-medium text-slate-600 dark:bg-slate-700 dark:text-slate-300">
                                <CheckIcon className="h-3 w-3" />
                                Always on
                              </span>
                            )}
                          </div>
                          <p id={descId} className="mt-1.5 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                            {cat.summary}
                          </p>
                          <div className="mt-3 flex flex-wrap gap-1.5">
                            {cat.providers.map((provider) => (
                              <span
                                key={provider}
                                className="rounded-md border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-medium text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400"
                              >
                                {provider}
                              </span>
                            ))}
                          </div>
                        </div>
                        <ToggleSwitch
                          checked={checked}
                          disabled={cat.required}
                          onChange={(next) => setDraftFor(cat.id, next)}
                          label={`${cat.name} cookies`}
                          describedBy={descId}
                        />
                      </div>
                    </div>
                  );
                })}
              </div>

              {/* Footer */}
              <div className="flex flex-col gap-3 border-t border-slate-200 p-6 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800">
                <button
                  type="button"
                  onClick={() => commit(ALL_OFF)}
                  className="inline-flex items-center justify-center rounded-xl px-4 py-2.5 text-sm font-semibold text-rose-600 transition-colors hover:bg-rose-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-rose-400 dark:hover:bg-rose-950/40 dark:focus-visible:ring-offset-slate-900"
                >
                  Reject all
                </button>
                <div className="flex flex-col gap-3 sm:flex-row">
                  <button
                    type="button"
                    onClick={() => commit(draft)}
                    className="inline-flex items-center justify-center rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-100 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-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
                  >
                    Save preferences
                  </button>
                  <button
                    type="button"
                    onClick={() => commit(ALL_ON)}
                    className="inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 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"
                  >
                    <CheckIcon className="h-4 w-4" />
                    Accept all
                  </button>
                </div>
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </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 →