Ein Cookie, wenn du erlaubst
Usamos cookies para que la web funcione y, si nos dejas, para saber qué te gusta. Tú decides.
Cookie-Banner mit Einstellungen
Ein Cookie-Hinweis im Papier-Look: alle akzeptieren, nur notwendige oder Schalter pro Kategorie, danach bleibt eine kleine Pille zum Ändern.
Einstellungen
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: "Notwendig", text: "Sitzung, Warenkorb und Sicherheit. Ohne sie funktioniert die Website nicht.", locked: true },
{ id: "analytics", label: "Analyse", text: "Anonyme Besuche, damit wir wissen, was wir verbessern können.", locked: false },
{ id: "marketing", label: "Marketing", text: "Personalisierte Werbung auf anderen 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 = "Ein Cookie, wenn du erlaubst",
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 ? "Einstellungen ausblenden" : "Anpassen"}
</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]">Immer aktiv</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, "Deine Auswahl wurde gespeichert")
: save({ necessary: true, analytics: false, marketing: false }, "Nur notwendige")
}
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 ? "Speichern" : "Nur notwendige"}
</button>
<button
type="button"
onClick={() => save({ necessary: true, analytics: true, marketing: true }, "Alle akzeptiert")}
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 }}
>
Alle akzeptieren
</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 für KI
Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.
Mehr aus Overlays
Wofür du ihn nutzen kannst
Fast jede Website in Europa braucht einen Cookie-Banner, also darf er ruhig zum Rest passen. Der warme Papier-Look steht einer Bäckerei, einem kleinen Restaurant oder einem Shop für Handgemachtes. Er schwebt als Karte oder liegt als Leiste am unteren Rand und blockiert die Seite nie.
Wichtig: Er zeigt die Auswahl nur an, speichert sie aber nicht und blockiert keine Skripte. Die Antwort musst du selbst sichern, etwa in einem Cookie, und Analytics erst nach der Zustimmung laden. Der Titel darf locker sein, solange klar bleibt, worum es geht.