React + MotionOverlays

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.