Cookie consent banner
@slashclone/cookie-consent-banner
Add an honest cookie banner to a web app: analytics, ad pixels, and video embeds load only after a yes, and saying no is one click. One registry of every cookie and service drives the banner, the loaders, the CSP, and the privacy page's cookie table. Includes a registry checker and React templates.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1"use client";
- 3/**
- 4 * A cookie banner and settings dialog for React. No dependencies beyond React.
- 5 * Props: the configured services (from your registry) and a loader that starts the
- 6 * trackers a choice allows. Style the class names to match your site.
- 7 */
- 8import { useEffect, useRef, useState } from "react";
- 9import {
- 10 CATEGORIES,
- 11 CONSENT_COOKIE,
- 12 CONSENT_MAX_AGE_SECONDS,
- 13 clearMatching,
- 14 effectiveConsent,
- 15 gpcOn,
- 16 parseConsent,
- 17 serializeConsent,
- 18 type Category,
- 19 type ConsentChoice,
- 20} from "./consent";
- 22export interface ServiceSummary {
- 23 id: string;
- 24 name: string;
- 25 category: Category;
- 26 /** Cookies and storage keys to delete when its category is turned off. */
- 27 clear?: RegExp;
- 28}
- 30const COPY: Record<Category, { title: string; body: string }> = {
- 31 analytics: { title: "Analytics", body: "Tells us which pages help, so we know what to build." },
- 32 advertising: { title: "Advertising", body: "Lets ad platforms count sign-ups from our ads." },
- 33 media: {
- 34 title: "Embedded videos",
- 35 body: "Plays videos from other sites, which may set cookies.",
- 36 },
- 37};
- 39export const OPEN_CONSENT_EVENT = "consent:open";
- 41function readStored(): ConsentChoice | null {
- 42 const part = document.cookie.split("; ").find((c) => c.startsWith(`${CONSENT_COOKIE}=`));
- 43 return parseConsent(part?.slice(CONSENT_COOKIE.length + 1));
- 44}
- 46export function ConsentBanner({
- 47 services,
- 48 onChoice,
- 49 policyHref = "/privacy#cookies",
- 50}: {
- 51 services: ServiceSummary[];
- 52 /** Load whatever the choice allows. Called on page load with a stored choice, and on save. */
- 53 onChoice: (choice: ConsentChoice) => void;
- 54 policyHref?: string;
- 55}) {
- 56 const [choice, setChoice] = useState<ConsentChoice | null>(null);
- 57 const [asking, setAsking] = useState(false);
- 58 const [gpc, setGpc] = useState(false);
- 59 const dialog = useRef<HTMLDialogElement>(null);
- 60 const ids = services.map((s) => s.id);
- 61 const idsKey = ids.join(",");
- 63 useEffect(() => {
- 64 setGpc(gpcOn());
- 65 const stored = effectiveConsent(readStored(), idsKey ? idsKey.split(",") : [], gpcOn());
- 66 setChoice(stored);
- 67 if (stored) onChoice(stored);
- 68 else setAsking(services.length > 0);
- 69 // onChoice and services are read once per set of service ids.
- 70 // eslint-disable-next-line react-hooks/exhaustive-deps
- 71 }, [idsKey]);
- 73 useEffect(() => {
- 74 const open = () => dialog.current?.showModal();
- 75 window.addEventListener(OPEN_CONSENT_EVENT, open);
- 76 return () => window.removeEventListener(OPEN_CONSENT_EVENT, open);
- 77 }, []);
- 79 const save = (next: Record<Category, boolean>) => {
- 80 const saved: ConsentChoice = {
- 81 v: 1,
- 82 ...next,
- 83 advertising: gpcOn() ? false : next.advertising,
- 84 s: ids,
- 85 at: Date.now(),
- 86 };
- 87 const secure = location.protocol === "https:" ? "; Secure" : "";
- 88 document.cookie = `${CONSENT_COOKIE}=${serializeConsent(saved)}; Path=/; Max-Age=${CONSENT_MAX_AGE_SECONDS}; SameSite=Lax${secure}`;
- 89 setAsking(false);
- 90 const revoked = choice ? CATEGORIES.filter((c) => choice[c] && !saved[c]) : [];
- 91 if (revoked.length > 0) {
- 92 for (const s of services) if (s.clear && revoked.includes(s.category)) clearMatching(s.clear);
- 93 location.reload(); // A loaded script cannot be unloaded.
- 94 return;
- 95 }
- 96 setChoice(saved);
- 97 onChoice(saved);
- 98 };
- 99 const all = (value: boolean) => save({ analytics: value, advertising: value, media: value });
- 100 const present = CATEGORIES.filter((c) => services.some((s) => s.category === c));
- 102 return (
- 103 <>
- 104 {asking ? (
- 105 <section aria-label="Cookie choices" className="consent-banner">
- 106 <h2>Your cookie choices</h2>
- 107 <p>
- 108 We use optional cookies for {present.map((c) => COPY[c].title.toLowerCase()).join(", ")}
- 109 . The site works either way, and you can change this any time from Cookie settings.{" "}
- 110 <a href={policyHref}>What we use</a>
- 111 </p>
- 112 {/* Reject and Accept look the same on purpose: saying no must be as easy as yes. */}
- 113 <button type="button" onClick={() => all(false)}>
- 114 Reject all
- 115 </button>
- 116 <button type="button" onClick={() => all(true)}>
- 117 Accept all
- 118 </button>
- 119 <button type="button" onClick={() => dialog.current?.showModal()}>
- 120 Choose
- 121 </button>
- 122 </section>
- 123 ) : null}
- 124 <dialog ref={dialog} aria-labelledby="consent-title" className="consent-dialog">
- 125 <form
- 126 key={choice ? String(choice.at) : "none"}
- 127 method="dialog"
- 128 onSubmit={(e) => {
- 129 const data = new FormData(e.currentTarget);
- 130 save({
- 131 analytics: data.get("analytics") === "on",
- 132 advertising: data.get("advertising") === "on",
- 133 media: data.get("media") === "on",
- 134 });
- 135 }}
- 136 >
- 137 <h2 id="consent-title">Cookie settings</h2>
- 138 <p>
- 139 Cookies that keep the site working are always on. Everything else is off until you turn
- 140 it on.
- 141 </p>
- 142 {present.map((c) => {
- 143 const locked = c === "advertising" && gpc;
- 144 return (
- 145 <label key={c}>
- 146 <input
- 147 type="checkbox"
- 148 name={c}
- 149 defaultChecked={!locked && (choice?.[c] ?? false)}
- 150 disabled={locked}
- 151 />
- 152 <strong>{COPY[c].title}</strong> {COPY[c].body} Uses:{" "}
- 153 {services
- 154 .filter((s) => s.category === c)
- 155 .map((s) => s.name)
- 156 .join(", ")}
- 157 .{locked ? " Off because your browser sends Global Privacy Control." : ""}
- 158 </label>
- 159 );
- 160 })}
- 161 <button type="submit">Save choices</button>
- 162 <button type="button" onClick={() => dialog.current?.close()}>
- 163 Cancel
- 164 </button>
- 165 </form>
- 166 </dialog>
- 167 </>
- 168 );
- 169}
- 171/** Put this in the footer of every page. */
- 172export function CookieSettingsLink() {
- 173 return (
- 174 <button type="button" onClick={() => window.dispatchEvent(new Event(OPEN_CONSENT_EVENT))}>
- 175 Cookie settings
- 176 </button>
- 177 );
- 178}
