Web InnoventixFreeCode

Cookie Alert

Original · free

A cookie-consent banner with accept and decline.

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

import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";

type ConsentChoice = "accepted" | "declined" | "custom";

type CookieCategory = {
  key: "essential" | "analytics" | "marketing";
  label: string;
  description: string;
  locked: boolean;
};

const CATEGORIES: CookieCategory[] = [
  {
    key: "essential",
    label: "Strictly necessary",
    description: "Keeps you signed in and remembers this choice. Always on.",
    locked: true,
  },
  {
    key: "analytics",
    label: "Analytics",
    description: "Anonymous page-view and performance data so we can fix slow pages.",
    locked: false,
  },
  {
    key: "marketing",
    label: "Marketing",
    description: "Lets us measure ad campaigns and show you relevant offers.",
    locked: false,
  },
];

export default function AlertCookie() {
  const [visible, setVisible] = useState(true);
  const [choice, setChoice] = useState<ConsentChoice | null>(null);
  const [showPrefs, setShowPrefs] = useState(false);
  const [prefs, setPrefs] = useState<Record<CookieCategory["key"], boolean>>({
    essential: true,
    analytics: true,
    marketing: false,
  });

  const headingId = useId();
  const descId = useId();
  const panelRef = useRef<HTMLDivElement | null>(null);
  const acceptRef = useRef<HTMLButtonElement | null>(null);

  useEffect(() => {
    if (visible) {
      const t = window.setTimeout(() => acceptRef.current?.focus(), 350);
      return () => window.clearTimeout(t);
    }
  }, [visible]);

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (e.key === "Escape" && !showPrefs) {
      // Escape = decline non-essential, the privacy-safe default.
      decline();
    }
  }

  function accept() {
    setPrefs({ essential: true, analytics: true, marketing: true });
    setChoice("accepted");
    setVisible(false);
  }

  function decline() {
    setPrefs({ essential: true, analytics: false, marketing: false });
    setChoice("declined");
    setVisible(false);
  }

  function saveCustom() {
    setChoice("custom");
    setShowPrefs(false);
    setVisible(false);
  }

  function toggle(key: CookieCategory["key"]) {
    setPrefs((p) => ({ ...p, [key]: !p[key] }));
  }

  function reopen() {
    setChoice(null);
    setShowPrefs(false);
    setVisible(true);
  }

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 dark:bg-slate-950 sm:px-6 sm:py-28">
      <style>{`
        @keyframes ckbnr-rise {
          from { opacity: 0; transform: translateY(16px) scale(0.985); }
          to { opacity: 1; transform: translateY(0) scale(1); }
        }
        @keyframes ckbnr-fade {
          from { opacity: 0; transform: translateY(-4px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes ckbnr-crumb {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-2px); }
        }
        .ckbnr-rise { animation: ckbnr-rise 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
        .ckbnr-fade { animation: ckbnr-fade 0.3s ease both; }
        .ckbnr-crumb { animation: ckbnr-crumb 3s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .ckbnr-rise, .ckbnr-fade, .ckbnr-crumb { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <div className="text-center">
          <h2 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
            Cookie consent banner
          </h2>
          <p className="mt-3 text-sm text-slate-600 dark:text-slate-400">
            Accept, decline, or tune each category. Your choice appears below.
          </p>
        </div>

        <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
          <div
            aria-live="polite"
            className="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300"
          >
            Current status:{" "}
            <span className="font-semibold text-slate-900 dark:text-white">
              {choice === "accepted"
                ? "All cookies accepted"
                : choice === "declined"
                ? "Non-essential declined"
                : choice === "custom"
                ? `Custom (${
                    [
                      prefs.analytics && "analytics",
                      prefs.marketing && "marketing",
                    ]
                      .filter(Boolean)
                      .join(" + ") || "essential only"
                  })`
                : "Awaiting your choice"}
            </span>
          </div>
          {!visible && (
            <button
              type="button"
              onClick={reopen}
              className="rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-950"
            >
              Reopen banner
            </button>
          )}
        </div>
      </div>

      {visible && (
        <div className="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex justify-center px-4 pb-4 sm:px-6 sm:pb-6">
          <div
            ref={panelRef}
            role="dialog"
            aria-modal="false"
            aria-labelledby={headingId}
            aria-describedby={descId}
            onKeyDown={onKeyDown}
            className="ckbnr-rise pointer-events-auto w-full max-w-2xl rounded-2xl border border-slate-200 bg-white/95 p-5 shadow-2xl shadow-slate-900/10 backdrop-blur-md dark:border-slate-800 dark:bg-slate-900/95 dark:shadow-black/40 sm:p-6"
          >
            <div className="flex items-start gap-4">
              <span
                aria-hidden="true"
                className="ckbnr-crumb mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400"
              >
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  className="h-6 w-6"
                  stroke="currentColor"
                  strokeWidth={1.8}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                >
                  <path d="M12 2a10 10 0 1 0 10 10 3 3 0 0 1-3-3 3 3 0 0 1-3-3 3 3 0 0 1-4-4Z" />
                  <path d="M8.5 10.5h.01M12 14.5h.01M15.5 9.5h.01M9 15.5h.01M14.5 16h.01" />
                </svg>
              </span>

              <div className="min-w-0 flex-1">
                <h3
                  id={headingId}
                  className="text-base font-semibold text-slate-900 dark:text-white"
                >
                  We use cookies to bake a better site
                </h3>
                <p
                  id={descId}
                  className="mt-1.5 text-sm leading-relaxed text-slate-600 dark:text-slate-400"
                >
                  Essential cookies keep the site running. With your OK we also use
                  analytics and marketing cookies to improve pages and measure
                  campaigns. Read our{" "}
                  <a
                    href="/privacy"
                    target="_blank"
                    rel="noopener"
                    className="font-medium text-indigo-600 underline decoration-indigo-300 underline-offset-2 hover:text-indigo-500 dark:text-indigo-400 dark:decoration-indigo-500/60"
                  >
                    cookie policy
                  </a>
                  .
                </p>

                {showPrefs && (
                  <div className="ckbnr-fade mt-4 space-y-2.5">
                    {CATEGORIES.map((cat) => {
                      const on = prefs[cat.key];
                      return (
                        <div
                          key={cat.key}
                          className="flex items-start justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50/70 p-3 dark:border-slate-800 dark:bg-slate-950/40"
                        >
                          <div className="min-w-0">
                            <p className="text-sm font-medium text-slate-800 dark:text-slate-200">
                              {cat.label}
                            </p>
                            <p className="mt-0.5 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
                              {cat.description}
                            </p>
                          </div>
                          <button
                            type="button"
                            role="switch"
                            aria-checked={on}
                            aria-label={`${cat.label} cookies`}
                            disabled={cat.locked}
                            onClick={() => !cat.locked && toggle(cat.key)}
                            className={[
                              "relative mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full transition focus: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",
                              cat.locked
                                ? "cursor-not-allowed bg-emerald-500/60"
                                : on
                                ? "bg-indigo-600"
                                : "bg-slate-300 dark:bg-slate-700",
                            ].join(" ")}
                          >
                            <span
                              className={[
                                "inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition",
                                on ? "translate-x-5" : "translate-x-0.5",
                              ].join(" ")}
                            />
                          </button>
                        </div>
                      );
                    })}
                  </div>
                )}

                <div className="mt-5 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
                  <button
                    ref={acceptRef}
                    type="button"
                    onClick={accept}
                    className="inline-flex items-center justify-center rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-500 focus: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"
                  >
                    Accept all
                  </button>
                  <button
                    type="button"
                    onClick={decline}
                    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 shadow-sm transition hover:bg-slate-50 focus: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"
                  >
                    Decline non-essential
                  </button>
                  {showPrefs ? (
                    <button
                      type="button"
                      onClick={saveCustom}
                      className="inline-flex items-center justify-center rounded-xl px-4 py-2.5 text-sm font-semibold text-indigo-600 transition hover:bg-indigo-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:hover:bg-indigo-500/10 dark:focus-visible:ring-offset-slate-900 sm:ml-auto"
                    >
                      Save preferences
                    </button>
                  ) : (
                    <button
                      type="button"
                      onClick={() => setShowPrefs(true)}
                      aria-expanded={showPrefs}
                      className="inline-flex items-center justify-center rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 transition hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900 sm:ml-auto"
                    >
                      Manage preferences
                    </button>
                  )}
                </div>
              </div>
            </div>
          </div>
        </div>
      )}
    </section>
  );
}

Dependencies

None (React + Tailwind only).

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 →