React + MotionOverlays

Cookie consent banner

A paper-toned cookie notice with accept all, necessary only or switches per type, plus a pill to change your mind later.

Settings

Code

import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Cookie } from "lucide-react";

const spring = { type: "spring" as const, stiffness: 380, damping: 34 };

const COOKIE_TYPES = [
  { id: "necessary", label: "Essential", text: "Session, cart, and security. The site won’t work without them.", locked: true },
  { id: "analytics", label: "Analytics", text: "Anonymous visits to learn what to improve.", locked: false },
  { id: "marketing", label: "Marketing", text: "Personalized ads on other websites.", locked: false },
] as const;

type CookiePrefs = { necessary: boolean; analytics: boolean; marketing: boolean };

interface CookieBannerProps {
  accent?: string;
  title?: string;
  layout?: "card" | "bar";
  onSave?: (prefs: CookiePrefs) => void;
}

export default function CookieBanner({
  accent = "#c2410c",
  title = "A cookie, if you don’t mind",
  layout = "card",
  onSave,
}: CookieBannerProps) {
  const [open, setOpen] = useState(true);
  const [expanded, setExpanded] = useState(false);
  const [prefs, setPrefs] = useState<CookiePrefs>({ necessary: true, analytics: false, marketing: false });
  const [status, setStatus] = useState("");
  const titleId = useId();
  const prefsId = useId();
  const reduce = useReducedMotion();
  const transition = reduce ? { duration: 0 } : spring;

  const save = (next: CookiePrefs, message: string) => {
    setPrefs(next);
    setStatus(message);
    setOpen(false);
    setExpanded(false);
    onSave?.(next);
  };

  return (
    <AnimatePresence>
      {open ? (
        // fixed: el aviso flota sobre la página; no es modal, se puede seguir navegando
        <motion.div
          key="banner"
          role="dialog"
          aria-labelledby={titleId}
          onKeyDown={(e) => {
            if (e.key === "Escape") setExpanded(false);
          }}
          layout
          initial={{ y: 48, opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
          exit={{ y: 48, opacity: 0 }}
          transition={transition}
          className={
            "fixed z-50 flex max-h-[calc(100dvh-24px)] flex-col border border-[#ebe4d6] bg-[#fffcf6] text-[#2b2620] shadow-[0_2px_6px_rgba(60,40,10,0.05),0_30px_70px_-20px_rgba(60,40,10,0.28)] dark:border-[#2c2922] dark:bg-[#1a1815] dark:text-[#efe9dd] dark:shadow-[0_30px_70px_-20px_rgba(0,0,0,0.7)] " +
            (layout === "bar" ? "inset-x-0 bottom-0 border-x-0 border-b-0" : "inset-x-3 bottom-3 mx-auto max-w-[400px]")
          }
          style={{ borderRadius: layout === "bar" ? "26px 26px 0 0" : 26 }}
        >
          <motion.div layout="position" className={"overflow-y-auto p-5 " + (layout === "bar" ? "mx-auto w-full max-w-2xl" : "")}>
            <div className="flex items-center gap-3">
              <span
                className="grid h-10 w-10 shrink-0 place-items-center rounded-full"
                style={{ color: accent, backgroundColor: "color-mix(in srgb, " + accent + " 12%, transparent)" }}
              >
                <Cookie className="h-5 w-5" strokeWidth={1.5} />
              </span>
              <h2 id={titleId} className="font-serif text-[20px] leading-tight">
                {title}
              </h2>
            </div>
            <p className="mt-3 text-[13px] leading-relaxed text-[#6b6257] dark:text-[#b3aa9b]">
              Usamos cookies para que la web funcione y, si nos dejas, para saber qué te gusta. Tú decides.{" "}
              <button
                type="button"
                aria-expanded={expanded}
                aria-controls={prefsId}
                onClick={() => setExpanded((x) => !x)}
                className="font-medium underline decoration-1 underline-offset-[3px]"
                style={{ color: accent }}
              >
                {expanded ? "Hide settings" : "Customize"}
              </button>
            </p>
            <AnimatePresence initial={false}>
              {expanded && (
                <motion.ul
                  id={prefsId}
                  key="prefs"
                  initial={{ opacity: 0, height: 0 }}
                  animate={{ opacity: 1, height: "auto" }}
                  exit={{ opacity: 0, height: 0 }}
                  transition={transition}
                  className="overflow-hidden"
                >
                  {COOKIE_TYPES.map((c, i) => {
                    const on = prefs[c.id];
                    return (
                      <li
                        key={c.id}
                        className={"flex items-center gap-3 py-3 " + (i === 0 ? "mt-3 " : "") + "border-t border-[#ebe4d6] dark:border-[#2c2922]"}
                      >
                        <div className="min-w-0 flex-1">
                          <p className="text-[13px] font-medium">
                            {c.label}
                            {c.locked && <span className="ml-1.5 text-[11px] font-normal text-[#a39a8c]">Always on</span>}
                          </p>
                          <p className="text-[12px] leading-snug text-[#a39a8c]">{c.text}</p>
                        </div>
                        <button
                          type="button"
                          role="switch"
                          aria-checked={on}
                          aria-label={c.label}
                          disabled={c.locked}
                          onClick={() => setPrefs((p) => ({ ...p, [c.id]: !p[c.id] }))}
                          className="relative h-6 w-10 shrink-0 rounded-full transition-colors duration-300 disabled:opacity-50"
                          style={{ backgroundColor: on ? accent : "rgba(120,113,108,0.3)" }}
                        >
                          <motion.span
                            className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow-[0_1px_3px_rgba(0,0,0,0.25)]"
                            animate={{ x: on ? 16 : 0 }}
                            transition={transition}
                          />
                        </button>
                      </li>
                    );
                  })}
                </motion.ul>
              )}
            </AnimatePresence>
            <div className="mt-4 flex gap-2">
              <button
                type="button"
                onClick={() =>
                  expanded
                    ? save(prefs, "Your choices are saved")
                    : save({ necessary: true, analytics: false, marketing: false }, "Only essential")
                }
                className="h-11 flex-1 rounded-full border border-[#e2d9c8] text-[13px] font-medium transition hover:bg-black/[0.03] active:scale-[0.98] dark:border-[#38342b] dark:hover:bg-white/[0.04]"
              >
                {expanded ? "Save" : "Essential only"}
              </button>
              <button
                type="button"
                onClick={() => save({ necessary: true, analytics: true, marketing: true }, "All accepted")}
                className="h-11 flex-1 rounded-full text-[13px] font-medium text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.2),0_8px_20px_-8px_rgba(0,0,0,0.45)] transition hover:brightness-110 active:scale-[0.98]"
                style={{ backgroundColor: accent }}
              >
                Accept all
              </button>
            </div>
          </motion.div>
        </motion.div>
      ) : (
        <motion.button
          key="reopen"
          type="button"
          onClick={() => setOpen(true)}
          initial={{ scale: 0.6, opacity: 0 }}
          animate={{ scale: 1, opacity: 1 }}
          exit={{ scale: 0.6, opacity: 0 }}
          transition={transition}
          className="fixed bottom-3 left-3 z-50 flex h-10 items-center gap-2 rounded-full border pl-1.5 pr-4 text-[12px] border-[#ebe4d6] bg-[#fffcf6] text-[#2b2620] shadow-[0_2px_6px_rgba(60,40,10,0.05),0_30px_70px_-20px_rgba(60,40,10,0.28)] dark:border-[#2c2922] dark:bg-[#1a1815] dark:text-[#efe9dd] dark:shadow-[0_30px_70px_-20px_rgba(0,0,0,0.7)]"
        >
          <span className="grid h-7 w-7 place-items-center rounded-full text-white" style={{ backgroundColor: accent }}>
            <Cookie className="h-4 w-4" strokeWidth={1.5} />
          </span>
          <span role="status">{status}</span>
          <span className="text-[#a39a8c]">· Cambiar</span>
        </motion.button>
      )}
    </AnimatePresence>
  );
}

Prompt for AI

Paste it into ChatGPT, Claude or Cursor and it will adapt the block to your project with these settings, even if you don’t use React.

More in Overlays

Where to use it

Almost every European site needs a cookie consent banner, so it might as well look like it belongs. This warm paper look suits a bakery, a small restaurant or a shop selling handmade goods. It floats as a card or sits as a bar along the bottom, and it never blocks the page.

It only shows the choice; it doesn't store it or block any scripts. You'll need to save the answer, in a cookie for example, and load analytics only after people say yes. Keep the title friendly but clear about what they're agreeing to.