# 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.

- Category: [Overlays](https://library.aimran.es/components/overlays)
- Built with: React + Motion
- Live editor: https://library.aimran.es/blocks/cookie-preferences-banner
- Library: AIMRAN Library (https://library.aimran.es), free, no sign-up, by Aimran Oukham

## 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.

## Code

### React (Motion)

```tsx
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 assistants

Paste this into ChatGPT, Claude, Cursor, v0 or Lovable to adapt the block to your stack (Tailwind, Vue, Svelte…):

````text
I want to add the “Cookie consent banner” block (Overlays) from AIMRAN Library to my project.

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

## Chosen settings
- Accent: #c2410c
- Title: "A cookie, if you don’t mind"
- Format: Floating card (other options: Bottom bar)

## What I need
1. Add it to my project with the same look and animation as the reference code: same sizes, colours, radii, shadows, timings and easing.
2. It is built with React + TypeScript, with framer-motion for the animation and lucide-react for the icons. If my project uses something else (Vue, Svelte, Astro, plain HTML…), translate it without losing any behaviour. If I use Tailwind, turn the styles into classes; if not, keep the CSS in its own file.
3. Expose the settings above as props (or CSS variables) with those values as defaults.
4. Make it work well on phones: nothing that depends only on hover, touch targets of at least 44 px and no overflow on 320 px screens.
5. Make it look right in light and dark mode.
6. Keep it accessible: semantic HTML, roles and aria-* where needed, visible focus and keyboard support.
7. Respect prefers-reduced-motion: with reduced motion, no big animations but the same end result.

## Reference code
```tsx
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>
  );
}
```

Give me the complete final code, ready to paste, and tell me in one line where to put it in my project.
````
