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