React + MotionOverlays

Bandeau de cookies

Un bandeau de cookies façon papier : tout accepter, le nécessaire seulement ou un choix par type, puis une pastille pour changer.

Réglages

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: "Nécessaires", text: "Session, panier et sécurité. Sans eux, le site ne fonctionne pas.", locked: true },
  { id: "analytics", label: "Analytiques", text: "Des visites anonymes pour savoir quoi améliorer.", locked: false },
  { id: "marketing", label: "Marketing", text: "Publicités personnalisées sur d’autres sites.", 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 = "Un cookie, si vous permettez",
  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 ? "Masquer les réglages" : "Personnaliser"}
              </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]">Toujours actifs</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, "Vos choix sont enregistrés")
                    : save({ necessary: true, analytics: false, marketing: false }, "Uniquement les essentiels")
                }
                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 ? "Enregistrer" : "Essentiels seulement"}
              </button>
              <button
                type="button"
                onClick={() => save({ necessary: true, analytics: true, marketing: true }, "Tous acceptés")}
                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 }}
              >
                Tout accepter
              </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 pour l’IA

Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.

Plus dans Overlays

Où l’utiliser

Presque tous les sites européens ont besoin d'un bandeau de consentement aux cookies, autant qu'il s'accorde avec le reste. Ce ton papier convient à une boulangerie, à un petit restaurant ou à une boutique d'artisanat. Il flotte comme une carte ou se pose en barre en bas de l'écran, sans jamais bloquer la page.

Attention : il affiche le choix mais ne l'enregistre pas et ne bloque aucun script. À vous de conserver la réponse, dans un cookie par exemple, et de ne charger les statistiques qu'après accord. Le titre peut être sympathique, tant qu'on comprend ce qu'on accepte.