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.

Version Markdown avec tout le code, pour les assistants IA

Plus dans Overlays

Voir tout : 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.