Un cookie, si vous permettez
Usamos cookies para que la web funcione y, si nos dejas, para saber qué te gusta. Tú decides.
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.