non-spooky-react-cookie 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1 @@
1
+ # non-spooky-react-cookie
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kamil Adamski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,577 @@
1
+ # non-spooky-react-cookie
2
+
3
+ [![npm](https://img.shields.io/npm/v/non-spooky-react-cookie)](https://www.npmjs.com/package/non-spooky-react-cookie)
4
+ [![CI](https://github.com/codingguydynamite/non-spooky-react-cookie/actions/workflows/ci.yml/badge.svg)](https://github.com/codingguydynamite/non-spooky-react-cookie/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/non-spooky-react-cookie)](./LICENSE)
6
+
7
+ A friendly, lightweight cookie consent manager for React and Next.js. Ship a GDPR-ready banner in a few lines, with built-in translations and sensible defaults — or dig into the full configuration when you need more. Optional scripts stay out of the DOM until the visitor accepts them. No spooky tracking before the visitor says yes.
8
+
9
+ Need a simple cookie banner? Have to respect EU privacy law? Want something portfolio-worthy, with out-of-the-box config and a growing set of examples for advanced setups? This is it.
10
+
11
+ > 🛡️ **Respects browser privacy signals.** Honors [Global Privacy Control](https://globalprivacycontrol.org/) (the signal Brave, Firefox and DuckDuckGo send) as "reject all" by default — and exposes `window.justDont()` so privacy extensions or power users can reject everything with one call, no banner interaction needed.
12
+
13
+ - Banner + settings dialog (native `<dialog>`, keyboard and screen-reader friendly)
14
+ - Categories and fine-grained items, each accepted independently
15
+ - The provider loads and unloads your third-party scripts with consent, including `cleanup`
16
+ - `useConsentScript`: a reactive `blocked | loading | loaded | error` status for any script
17
+ - Honors [Global Privacy Control](https://globalprivacycontrol.org/) out of the box, and exposes `window.justDont()` for one-call "reject all" (see below)
18
+ - Persist to localStorage, a cookie, both, or your own adapter; read the decision on the server
19
+ - Built-in `en` / `de` / `pl` texts, fully typed overrides
20
+ - Plain CSS with `--nsr-*` variables and dark mode. No Tailwind or other framework required
21
+ - React 18+, works with React 19 and the Next.js App Router
22
+
23
+ Try every feature in the [examples playground](./examples/README.md).
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pnpm add non-spooky-react-cookie
29
+ # or: npm install non-spooky-react-cookie
30
+ ```
31
+
32
+ Import the stylesheet once, anywhere in your app (for Next.js: `app/layout.tsx`):
33
+
34
+ ```ts
35
+ import "non-spooky-react-cookie/styles.css";
36
+ ```
37
+
38
+ ## Basic setup
39
+
40
+ ```tsx
41
+ import { CookieBanner, CookieBannerConfigurationProvider } from "non-spooky-react-cookie";
42
+
43
+ export function Providers({ children }: { children: React.ReactNode }) {
44
+ return (
45
+ <CookieBannerConfigurationProvider language="de" storageKey="my-site-cookies">
46
+ {children}
47
+ <CookieBanner policyUrl="/datenschutz" />
48
+ </CookieBannerConfigurationProvider>
49
+ );
50
+ }
51
+ ```
52
+
53
+ In the Next.js App Router put this in a client component (`"use client"`) and render it from your root layout. The package's main entry is itself a client module.
54
+
55
+ ## What you get
56
+
57
+ - `CookieBannerConfigurationProvider` – owns the state, storage, texts, theme, callbacks, **and** the loading/unloading of your third-party scripts
58
+ - `CookieBanner` – the first layer visitors see (with the settings dialog built in)
59
+ - `CookieSettingsDialog` – the full cookie settings dialog (rendered automatically by `CookieBanner`)
60
+ - `CookieSettingsLink` – a small link (e.g. in a footer) that opens the cookie settings dialog
61
+ - `Button` / `Switch` / `Collapsible` – the default primitives, exported so you can wrap or reuse them
62
+ - `usePreferences` – the hook for consent state and actions
63
+ - `useConsentScript` – **reactive**, consent-gated load status (`blocked | loading | loaded | error`) for a script declared in the provider
64
+ - `initGoogleTracker` / `updateGoogleTracker` – Google consent mode sync
65
+ - `localStorageAdapter` / `createCookieStorage` / `createBothStorage` – the built-in storage adapters
66
+ - `getBuiltInTexts` / `BUILT_IN_LANGUAGES` – the built-in en/de/pl texts as plain JSON, e.g. for a CMS field's default value
67
+ - `non-spooky-react-cookie/server` → `readPreferencesFromCookies`, `getBuiltInTexts`, `BUILT_IN_LANGUAGES` – server-safe (no React, no `window`)
68
+ - Types for texts: `Texts`, `TextOverrides`, `DeepPartialNullable`, `BuiltInLanguage` (see "Your own texts")
69
+
70
+ ## Defining consent categories
71
+
72
+ Categories are groups. Items are fine-grained entries inside a category, e.g. "Meta Pixel" inside Marketing. Each item is accepted **independently** — a script gated on an item loads as soon as that item is on, even if the category's master switch is off. The category switch is a convenience that toggles all of its items at once; it is not a hard requirement for the items.
73
+
74
+ Pass the categories as an object map via the `config` prop:
75
+
76
+ ```tsx
77
+ const consentConfig = {
78
+ categories: {
79
+ necessary: { required: true, name: "Necessary" },
80
+ preferences: { name: "Preferences" },
81
+ analytics: { name: "Analytics" },
82
+ marketing: {
83
+ name: "Marketing",
84
+ items: {
85
+ "meta-pixel": {
86
+ name: "Meta Pixel",
87
+ description: "Tracks visits and conversions on Facebook.",
88
+ },
89
+ "google-ads": {
90
+ name: "Google Ads",
91
+ description: "Enables remarketing campaigns.",
92
+ },
93
+ },
94
+ },
95
+ },
96
+ };
97
+
98
+ <CookieBannerConfigurationProvider config={consentConfig}>
99
+ {children}
100
+ </CookieBannerConfigurationProvider>
101
+ ```
102
+
103
+ Omit `config` entirely and the provider falls back to the built-in `necessary` / `preferences` / `analytics` / `marketing` set.
104
+
105
+ ## Managing third-party scripts
106
+
107
+ Declare your scripts on the provider as an **object map keyed by script id**. The provider loads each one only when its `category` (a category id, or an item id) is accepted, and unloads it when consent is withdrawn. The key is used as the `<script>` element id and for deduplication.
108
+
109
+ ```tsx
110
+ const scripts = {
111
+ "google-analytics": {
112
+ category: "analytics",
113
+ src: "https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX",
114
+ },
115
+ hotjar: {
116
+ category: "analytics",
117
+ src: "https://static.hotjar.com/c_hotjar-next.js",
118
+ async: true,
119
+ },
120
+ "meta-pixel": {
121
+ category: "meta-pixel", // item id — loads when this item is accepted (parent not required)
122
+ src: "https://connect.facebook.net/en_US/fbevents.js",
123
+ cleanup: () => {
124
+ // undo side effects the script caused (window globals, listeners, …)
125
+ delete (window as any).fbq;
126
+ },
127
+ },
128
+ };
129
+
130
+ <CookieBannerConfigurationProvider config={consentConfig} scripts={scripts}>
131
+ {children}
132
+ </CookieBannerConfigurationProvider>
133
+ ```
134
+
135
+ That's the whole API. No `if (isAllowed("analytics"))` checks, no wrapper components — the provider is the single enforcement point.
136
+
137
+ ### Script options
138
+
139
+ The script id is the **key** in the `scripts` map (not a field). Each entry accepts:
140
+
141
+ | Prop | Description |
142
+ | --- | --- |
143
+ | `category` | Category id or item id that must be accepted. Items are accepted independently of their parent category. |
144
+ | `src` | URL of the script. Omit for inline scripts. |
145
+ | `children` | Inline script body. |
146
+ | `attrs` | Extra attributes, e.g. `{ "data-foo": "bar" }`. |
147
+ | `async` | Set `script.async`. **Wins over `defer`** when both are set (setting both is invalid in HTML). |
148
+ | `defer` | Set `script.defer`. Ignored when `async` is set. |
149
+ | `onLoad` / `onError` | Load callbacks. |
150
+ | `cleanup` | Runs when consent is withdrawn and the script is removed. Use it to undo globals, listeners, or other side effects. |
151
+
152
+ ### What happens on withdrawal
153
+
154
+ When a category (or item) flips from accepted to rejected, the provider:
155
+
156
+ 1. Removes the `<script>` element from the DOM.
157
+ 2. Runs the script's `cleanup` function (if provided), swallowing any error.
158
+
159
+ `cleanup` only runs for a script that was actually loaded — a script that was never accepted is left alone when other preferences change.
160
+
161
+ **Caveat:** none of this undoes cookies the script already set or network requests it already fired. For real teardown (e.g. calling `fbq('shutdown')`), use the `cleanup` hook.
162
+
163
+ ### Composing scripts from several modules
164
+
165
+ The `scripts` prop is the **only** place scripts are declared — there is no side channel to register one from elsewhere. If an integration lives in its own module, export its part of the map and spread it in:
166
+
167
+ ```tsx
168
+ // integrations/hotjar.ts
169
+ import type { ConsentScripts } from "non-spooky-react-cookie";
170
+
171
+ export const hotjarScripts: ConsentScripts = {
172
+ hotjar: { category: "analytics", src: "https://static.hotjar.com/c/hotjar-XXXX.js" },
173
+ };
174
+
175
+ // app root
176
+ <CookieBannerConfigurationProvider scripts={{ ...hotjarScripts, ...metaScripts }} />
177
+ ```
178
+
179
+ Keep the object reference stable (module-level constant or `useMemo`); a new `scripts` object on every render unloads and reloads the scripts.
180
+
181
+ ## useConsentScript (reactive load status)
182
+
183
+ The real value of the library: a reactive 4-state load status for any script. The banner is just the UI — the app owns the actual integration (e.g. a `GoogleMap` component with custom pins), and `useConsentScript` tells it when it's safe to use the loaded global.
184
+
185
+ ```tsx
186
+ import { useConsentScript, usePreferences } from "non-spooky-react-cookie";
187
+
188
+ function GoogleMap({ locations }: { locations: Location[] }) {
189
+ const { status, error } = useConsentScript("google-maps");
190
+ const { openSettings } = usePreferences();
191
+
192
+ if (status === "blocked") {
193
+ return (
194
+ <div>
195
+ Google Maps requires functional cookies.
196
+ <button onClick={openSettings}>Manage consent</button>
197
+ </div>
198
+ );
199
+ }
200
+ if (status === "loading") return <MapSkeleton />;
201
+ if (status === "error") return <MapError error={error} />;
202
+
203
+ // `google.maps` is guaranteed to be available here.
204
+ return <ActualGoogleMap locations={locations} />;
205
+ }
206
+ ```
207
+
208
+ `status` is one of:
209
+
210
+ | Status | Meaning |
211
+ | --- | --- |
212
+ | `blocked` | Consent for the script's category is not granted. |
213
+ | `loading` | Consent granted, the script is being fetched. |
214
+ | `loaded` | The `<script>` is in the DOM and finished loading. |
215
+ | `error` | The script failed to load, the id is not in the provider's `scripts` map, or the hook is rendered outside a provider. |
216
+
217
+ The status is gated on the script's `category`; the hook itself never loads anything — the provider does, so it must be rendered inside a `CookieBannerConfigurationProvider`. The `<script>` element is created exactly once (deduped by id), so many components can call this for the same id safely.
218
+
219
+ ## Your own texts (fully typed)
220
+
221
+ `language` picks the built-in texts (`en` default, `de`, `pl`). Region codes resolve to their base language, so `"pl-PL"` or `"de_AT"` work too; any other language falls back to English. `texts` lets you override or extend any string — every field is typed, so you get full autocomplete.
222
+
223
+ ```tsx
224
+ <CookieBannerConfigurationProvider
225
+ language="de"
226
+ texts={{
227
+ banner: {
228
+ title: "Unsere Datenschutzeinstellungen",
229
+ },
230
+ categories: {
231
+ marketing: {
232
+ items: {
233
+ "meta-pixel": {
234
+ title: "Meta Pixel (Facebook)",
235
+ },
236
+ },
237
+ },
238
+ },
239
+ }}
240
+ >
241
+ {children}
242
+ </CookieBannerConfigurationProvider>
243
+ ```
244
+
245
+ Category and item names passed through `config` win over `texts`.
246
+
247
+ `dialog.itemsLabel` labels the trigger that reveals a category's items. The count follows in parentheses, "Show services (2)", so the label needs no plural forms.
248
+
249
+ Pass a stable `texts` object: define it outside the component or wrap it in `useMemo`. The provider re-merges the texts whenever the object's identity changes, so an inline literal redoes that work on every render.
250
+
251
+ ### With react-i18next (or any other i18n library)
252
+
253
+ The library has no `t()` of its own. Feed it your translations through `texts`:
254
+
255
+ ```tsx
256
+ import { useMemo } from "react";
257
+ import { useTranslation } from "react-i18next";
258
+ import { CookieBannerConfigurationProvider, type TextOverrides } from "non-spooky-react-cookie";
259
+
260
+ function CookieProvider({ children }: { children: React.ReactNode }) {
261
+ const { t, i18n } = useTranslation();
262
+
263
+ const texts = useMemo<TextOverrides>(
264
+ () => ({
265
+ banner: { title: t("cookies.banner.title"), acceptAll: t("cookies.banner.acceptAll") },
266
+ dialog: { itemsLabel: (count) => t("cookies.dialog.services", { count }) },
267
+ }),
268
+ [t],
269
+ );
270
+
271
+ return (
272
+ <CookieBannerConfigurationProvider language={i18n.resolvedLanguage} texts={texts}>
273
+ {children}
274
+ </CookieBannerConfigurationProvider>
275
+ );
276
+ }
277
+ ```
278
+
279
+ Strings you leave out come from the built-in texts for `language`.
280
+
281
+ ### Texts from a CMS
282
+
283
+ Texts can come from a headless CMS or a database, one document per locale, and be edited by people who never touch the code. Three rules make that work:
284
+
285
+ - `null` counts as not set. A CMS returns a field an editor left blank as `null`, and the built-in text for that field stays. An empty string `""` is a deliberate value and replaces it.
286
+ - Every text is a plain string, so overrides and the built-in texts are plain JSON.
287
+ - `TextOverrides` is the type for this: every field optional, every field nullable. A CMS's generated document type with `string | null` fields usually assigns to it with no mapping. It is built on `DeepPartialNullable<T>`, which is exported for your own shapes.
288
+
289
+ In Next.js, read the document in a server component and pass it down. The provider is a client component, so whatever crosses into it has to be serializable. `scripts` carries callbacks, so declare it in a client module:
290
+
291
+ ```tsx
292
+ // app/[lang]/cookie-consent.tsx
293
+ "use client";
294
+
295
+ import {
296
+ CookieBanner,
297
+ CookieBannerConfigurationProvider,
298
+ type ConsentScripts,
299
+ type TextOverrides,
300
+ } from "non-spooky-react-cookie";
301
+
302
+ const scripts: ConsentScripts = {
303
+ plausible: { category: "analytics", src: "https://plausible.io/js/script.js", defer: true },
304
+ };
305
+
306
+ export function CookieConsent(props: {
307
+ language: string;
308
+ texts: TextOverrides | null;
309
+ policyUrl: string;
310
+ children: React.ReactNode;
311
+ }) {
312
+ return (
313
+ <CookieBannerConfigurationProvider
314
+ language={props.language}
315
+ texts={props.texts}
316
+ scripts={scripts}
317
+ storage="cookie"
318
+ >
319
+ {props.children}
320
+ <CookieBanner policyUrl={props.policyUrl} />
321
+ </CookieBannerConfigurationProvider>
322
+ );
323
+ }
324
+ ```
325
+
326
+ ```tsx
327
+ // app/[lang]/layout.tsx (a server component)
328
+ const texts = await cms.getCookieBannerTexts(lang); // null fields are fine
329
+ return <CookieConsent language={lang} texts={texts} policyUrl={`/${lang}/privacy`}>{children}</CookieConsent>;
330
+ ```
331
+
332
+ Keep the category ids in code, next to `scripts`, because a script names the category that gates it. The CMS then supplies only the words, under `texts.categories[<id>]`.
333
+
334
+ `getBuiltInTexts(language)` and `BUILT_IN_LANGUAGES` (typed as `BuiltInLanguage`) are exported from both entries, `non-spooky-react-cookie` and `non-spooky-react-cookie/server`. Use them to fill a CMS field's default value, or to see which languages need a translation before they stop falling back to English.
335
+
336
+ ## Styling
337
+
338
+ The package ships one small stylesheet and no framework dependency. It follows `dir="rtl"` on any ancestor: text aligns to the start and the switch moves the other way.
339
+
340
+ Three layers, from simplest to most control:
341
+
342
+ ### 1. The `theme` prop
343
+
344
+ Provide any subset of colors; everything else keeps the built-in look. Five colors are the real inputs: `primaryColor`, `primaryTextColor`, `accentColor`, `surfaceColor` and `textColor`. Muted text, borders, secondary buttons, hover backgrounds, the switch track and thumb and the focus ring are derived from those with `color-mix()`, so a dark surface with light text gets matching everything. Set a derived color (`mutedTextColor`, `borderColor`, `surfaceMutedColor`, `secondaryColor`, `secondaryTextColor`, `primaryHoverColor`, `ringColor`, `switchOffColor`, `switchThumbColor`, `backdropColor`) only when you want to override the derivation.
345
+
346
+ ```tsx
347
+ <CookieBannerConfigurationProvider
348
+ theme={{
349
+ primaryColor: "#0ea5e9",
350
+ primaryTextColor: "#ffffff",
351
+ accentColor: "#0284c7",
352
+ }}
353
+ darkTheme={{
354
+ primaryColor: "#7dd3fc",
355
+ primaryTextColor: "#082f49",
356
+ }}
357
+ >
358
+ {children}
359
+ </CookieBannerConfigurationProvider>
360
+ ```
361
+
362
+ `theme` applies in both light and dark mode. `darkTheme` applies only under a `.dark` or `[data-theme="dark"]` ancestor and falls back to `theme`, then to the built-in dark palette, for anything it does not set. Without `darkTheme`, a `theme` that sets `surfaceColor` should set `textColor` too, or dark mode will put the built-in light text on your surface.
363
+
364
+ The provider renders one small `<style>` element with the values, scoped by a `data-nsr-theme` attribute that the banner, the dialog and `CookieSettingsLink` carry. To theme an element of your own the same way, spread `usePreferences().themeAttributes` onto it.
365
+
366
+ Pick primary/text pairs with at least 4.5:1 contrast (the built-in ones do); the library does not adjust text color automatically.
367
+
368
+ ### 2. CSS custom properties
369
+
370
+ Override the variables globally or per theme. The built-in dark palette applies under a `.dark` **or** `[data-theme="dark"]` ancestor (Tailwind's class strategy and `next-themes` both work out of the box).
371
+
372
+ ```css
373
+ :root {
374
+ --nsr-primary: #0ea5e9;
375
+ --nsr-radius: 0.5rem; /* corner radius of cards and buttons */
376
+ --nsr-font: "Inter", sans-serif;
377
+ --nsr-z-banner: 90;
378
+ --nsr-z-dialog: 100;
379
+ }
380
+ .dark {
381
+ --nsr-surface: #0b1120;
382
+ }
383
+ ```
384
+
385
+ Inputs: `--nsr-primary`, `--nsr-primary-text`, `--nsr-accent`, `--nsr-surface`, `--nsr-text`, `--nsr-backdrop`, `--nsr-radius`, `--nsr-font`, `--nsr-z-banner`, `--nsr-z-dialog`.
386
+ Derived unless you set them: `--nsr-muted`, `--nsr-border`, `--nsr-surface-muted`, `--nsr-secondary`, `--nsr-secondary-text`, `--nsr-primary-hover`, `--nsr-ring`, `--nsr-switch-off`, `--nsr-switch-thumb`.
387
+
388
+ ### 3. Classes and your own components
389
+
390
+ Every part carries a stable `nsr-*` class (`nsr-banner`, `nsr-banner__card`, `nsr-button--primary`, `nsr-switch`, `nsr-dialog__panel`, `nsr-category`, `nsr-item`, …), and every component accepts `className` plus per-part class props. Your classes are appended, so Tailwind utilities work fine. You can also swap the default `Button` / `Switch` / `Collapsible` for your own components via `components`: on the provider for everywhere, on `CookieBanner` for the banner and the dialog it renders, or in `dialogProps.components` for that dialog only.
391
+
392
+ ```tsx
393
+ <CookieBanner
394
+ contentClassName="rounded-none border-dashed"
395
+ buttonClassName="w-full"
396
+ components={{ Button: MyButton }}
397
+ dialogProps={{ contentClassName: "max-w-3xl", buttonClassName: "rounded-full" }}
398
+ />
399
+ ```
400
+
401
+ `CookieBanner` renders the settings dialog for you; style it through `dialogProps` rather than rendering a second `CookieSettingsDialog`. The dialog is a native `<dialog>` (top layer, focus trap, Escape-to-close built in); its dim/blur backdrop is `--nsr-backdrop`, and `overlayClassName` is applied to the click-to-close layer behind the panel. `CookieBanner` renders the privacy-policy link only when you pass `policyUrl`.
402
+
403
+ ## usePreferences
404
+
405
+ ```tsx
406
+ import { usePreferences } from "non-spooky-react-cookie";
407
+
408
+ function MyComponent() {
409
+ const {
410
+ loaded,
411
+ hasDecision,
412
+ preferences,
413
+ acceptAll,
414
+ rejectAll,
415
+ savePreferences,
416
+ resetPreferences,
417
+ openSettings,
418
+ closeSettings,
419
+ isAllowed,
420
+ } = usePreferences();
421
+
422
+ if (!isAllowed("analytics")) return null;
423
+ return <ChartWidget />;
424
+ }
425
+ ```
426
+
427
+ ## window.justDont()
428
+
429
+ As soon as the provider mounts, a single global function is available from the DevTools console — no extra prop, no script tag, no page reload:
430
+
431
+ ```js
432
+ window.justDont(); // rejects every optional category
433
+ ```
434
+
435
+ It does exactly what the "Reject all" button does: required categories stay on, optional ones (and their items) are turned off, the banner closes, consent-gated scripts are unloaded (with their `cleanup`), the decision is persisted, and `onDecision` / Google consent mode react. So an "I don't care about cookies"-style browser extension needs only:
436
+
437
+ ```js
438
+ // content script, run in the page context
439
+ () => window.justDont?.()
440
+ ```
441
+
442
+ The provider registers the function on mount and removes it on unmount. If `window.justDont` already exists (another banner, your own code) the provider logs a warning and takes over the slot. Opt out with `windowJustDont={false}` on the provider.
443
+
444
+ ## Global Privacy Control
445
+
446
+ [Global Privacy Control](https://globalprivacycontrol.org/) (GPC) is a browser setting that says "do not sell or share my data". Browsers that support it (Firefox, Brave, DuckDuckGo; extensions such as Privacy Badger add it elsewhere) send a `Sec-GPC: 1` header and expose `navigator.globalPrivacyControl === true`. The provider honors it out of the box; opt out with one prop:
447
+
448
+ ```tsx
449
+ <CookieBannerConfigurationProvider respectGlobalPrivacyControl={false}>
450
+ ```
451
+
452
+ By default, a visitor whose browser sends the signal and who has no stored decision for the current `version` is treated as if they clicked "Reject all": required categories stay on, optional ones stay off, consent-gated scripts stay out, `onDecision` fires, and the banner never shows. Three rules keep this predictable:
453
+
454
+ - A decision the visitor already made on your site always wins over the signal.
455
+ - The signal-driven decision is not persisted. The signal is live, so turning it off in the browser brings the banner back on the next visit.
456
+ - The visitor can still opt in through the settings dialog (`CookieSettingsLink`), and that choice is persisted as usual.
457
+
458
+ `usePreferences().globalPrivacyControl` tells you whether the signal was detected, so you can show a small "we honored your browser's privacy setting" note instead of a banner. How a GPC signal maps onto consent is a decision the spec leaves to the publisher; the default here is the privacy-friendly reading, and `respectGlobalPrivacyControl={false}` turns it off if your legal setup needs the banner regardless.
459
+
460
+ ## Provider options
461
+
462
+ ```tsx
463
+ <CookieBannerConfigurationProvider
464
+ language="de"
465
+ storageKey="my-site-cookies"
466
+ version="2026-08-21"
467
+ onDecision={(state) => {
468
+ console.log(state.accepted);
469
+ }}
470
+ >
471
+ {children}
472
+ </CookieBannerConfigurationProvider>
473
+ ```
474
+
475
+ - `config` – consent categories (object map, keyed by category id)
476
+ - `scripts` – third-party scripts to manage (object map, keyed by script id — see "Managing third-party scripts")
477
+ - `language` – `"en"` (default), `"de"` or `"pl"` for the built-in texts; region codes like `"pl-PL"` resolve to the base language.
478
+ - `texts` – typed overrides of any built-in string, where `null` keeps the built-in one; pass a stable (memoized) object (see "Texts from a CMS")
479
+ - `theme` – color palette, `darkTheme` – dark-mode overrides (see "Styling")
480
+ - `components` – swap the default `Button` / `Switch`
481
+ - `storageKey` – localStorage key and/or cookie name (default `"non-spooky-react-cookie"`)
482
+ - `storage` – where the decision is persisted: `"localStorage"` (default), `"cookie"`, `"both"`, or a custom adapter (see "Storage")
483
+ - `cookieOptions` – cookie attributes for `"cookie"` / `"both"` (see "Storage")
484
+ - `initialPreferences` – decision read on the server, so the first render already matches (see "Storage")
485
+ - `version` – bump this to ask visitors again (old stored state is ignored)
486
+ - `googleConsentMode` – opt in to Google consent mode sync (see "Google consent mode")
487
+ - `windowJustDont` – register the `window.justDont()` global (default `true`; set `false` to opt out — see "window.justDont()")
488
+ - `respectGlobalPrivacyControl` – treat the browser's GPC signal as "Reject all" (default `true`; set `false` to opt out — see "Global Privacy Control")
489
+ - `onDecision` – called whenever the visitor makes or changes their choice
490
+
491
+ ## Storage
492
+
493
+ The decision (`PreferencesState`: `version`, `updatedAt`, `accepted`) is stored as JSON under `storageKey`. Pick where with the `storage` prop:
494
+
495
+ | `storage` | Where | Server can read it | Notes |
496
+ | ------------------ | ---------------------------------- | ------------------ | ----- |
497
+ | `"localStorage"` | `window.localStorage` (default) | no | per origin |
498
+ | `"cookie"` | a cookie named `storageKey` | yes | can span subdomains via `cookieOptions.domain` |
499
+ | `"both"` | cookie **and** localStorage | yes | reads the cookie first, then localStorage |
500
+
501
+ `"both"` is the safe choice when a site moves from localStorage to cookies: visitors who already decided keep their choice (read from localStorage), and the next decision is written to both. The cookie wins on read because it is the copy a server can see.
502
+
503
+ ```tsx
504
+ <CookieBannerConfigurationProvider
505
+ storage="cookie"
506
+ cookieOptions={{ domain: ".example.com", maxAge: 60 * 60 * 24 * 180 }}
507
+ >
508
+ ```
509
+
510
+ Cookie defaults: `Path=/`, `Max-Age=31536000` (365 days), `SameSite=Lax`, `Secure` on https. `sameSite: "none"` always sets `Secure`. The payload is the url-encoded JSON state, a few hundred bytes for typical configs.
511
+
512
+ ### Custom adapter
513
+
514
+ `storage` also accepts any object with `get`, `set` and `remove` working on strings. The library does the JSON parsing and validation, so the adapter never sees the state shape. Keep the adapter reference stable (module-level constant or `useMemo`).
515
+
516
+ ```tsx
517
+ const memoryStorage: PreferencesStorage = {
518
+ get: (key) => store.get(key) ?? null,
519
+ set: (key, value) => void store.set(key, value),
520
+ remove: (key) => void store.delete(key),
521
+ };
522
+
523
+ <CookieBannerConfigurationProvider storage={memoryStorage}>
524
+ ```
525
+
526
+ The built-in adapters are exported too: `localStorageAdapter`, `createCookieStorage(options)`, `createBothStorage(options)`.
527
+
528
+ ### Reading the decision on the server
529
+
530
+ With `"cookie"` or `"both"`, a server can read the decision before rendering. `readPreferencesFromCookies` lives in the **server entry** `non-spooky-react-cookie/server`, which has no React and no `window`, so it is safe in server components, route handlers, and middleware. Pass it the raw `Cookie` header or a cookie store with `get(name)` such as the one from Next.js `cookies()`. Hand the result to `initialPreferences` so the first render already knows the decision — no banner flash, and `usePreferences().loaded` is `true` from the start.
531
+
532
+ ```tsx
533
+ // app/layout.tsx (server component)
534
+ import { cookies } from "next/headers";
535
+ import { readPreferencesFromCookies } from "non-spooky-react-cookie/server";
536
+ import { ConsentProvider } from "./consent-provider"; // your "use client" wrapper
537
+
538
+ export default async function RootLayout({ children }) {
539
+ const initial = readPreferencesFromCookies(await cookies(), "my-site-cookies");
540
+ return <ConsentProvider initialPreferences={initial}>{children}</ConsentProvider>;
541
+ }
542
+ ```
543
+
544
+ Reading `cookies()` makes the route dynamic, so this needs a Node or edge runtime — it does not work with `output: "export"`. After mount the provider re-reads the client storage, which stays the source of truth.
545
+
546
+ ## Google consent mode
547
+
548
+ If you use Google tags, set `googleConsentMode` on the provider. It then initializes consent mode with everything denied and updates it on every decision, based on the `analytics` and `marketing` categories. Without the prop the provider never touches `window.gtag` / `window.dataLayer`.
549
+
550
+ ```tsx
551
+ <CookieBannerConfigurationProvider googleConsentMode scripts={scripts}>
552
+ {children}
553
+ </CookieBannerConfigurationProvider>
554
+ ```
555
+
556
+ Because consent can be fine-grained, a category grants its signals when the category itself **or any of its items** is accepted — so accepting only "Google Ads" (Marketing master off) still grants `ad_storage`, matching the scripts that actually load.
557
+
558
+ `updateGoogleTracker(state, categories?)` accepts the category list as an optional second argument for that item-level behavior; without it, it falls back to the plain `analytics` / `marketing` category ids.
559
+
560
+ ## Examples
561
+
562
+ The [`examples/vite-playground`](./examples/README.md) app has one page per feature: basic banner, fine-grained items, consent-gated scripts, loading a library only after consent, every storage strategy, a custom adapter, SSR initial preferences, languages, texts from a CMS with right-to-left, theming and dark mode, custom components, Google consent mode, version bumps, and programmatic control.
563
+
564
+ ```bash
565
+ git clone https://github.com/codingguydynamite/non-spooky-react-cookie
566
+ cd non-spooky-react-cookie
567
+ pnpm install
568
+ pnpm example
569
+ ```
570
+
571
+ ## Contributing and releasing
572
+
573
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the development setup and [MAINTAINING.md](./MAINTAINING.md) for how versions are cut and published.
574
+
575
+ ## License
576
+
577
+ [MIT](./LICENSE) © Kamil Adamski