Skip to content
SlashClone

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.

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

Cookies that keep you signed in and remember these choices are always on. Everything else is off until you turn it on.