Minimal, CNIL-friendly cookie-consent for React — one consent category, accept/refuse, re-openable, versioned 6-month persistence. No runtime dependency, no Tailwind: the banner ships its own standalone CSS and inherits your font.
- Stores the choice (acceptance and refusal) in a first-party cookie for 182 days (~6 months, the CNIL ceiling before re-asking).
versionbump → every visitor is re-prompted (use it when your tracker roster changes).- Malformed/unknown cookie → treated as
pending, never throws. - SSR-safe: server HTML and first client render both see
pending(no banner, no tracker), the cookie is read after mount — no hydration mismatch, no banner flash for returning visitors. - Non-blocking banner (
role="dialog"aria-modal="false", no overlay, no focus trap), accept and refuse rendered with equal prominence — both CNIL requirements. reopen()re-prompts without dropping the stored choice: the previous consent record stays valid until the user actually decides again.
pnpm add @snowpact/snowcookiePeer dependency: react >= 18.
import { SnowCookie, SnowCookieProvider, useSnowCookie } from '@snowpact/snowcookie';
import '@snowpact/snowcookie/styles.css';CSS import order matters: import styles.css after your global/reset stylesheet (Tailwind preflight, normalize…). Resets target [type='button'] with the same specificity as the banner classes — imported first, the package CSS would lose the button styling coin-flip.
Wrap your whole app shell with the provider — including the footer if it hosts the "Manage cookies" button (reopen needs the context):
<SnowCookieProvider version={1} cookieName="my_app_cookie_consent">
<App />
</SnowCookieProvider>Gate your trackers and mount the banner anywhere below the provider:
const { isGranted } = useSnowCookie();
{isGranted && <MyAnalyticsScript />}
<SnowCookie
title="Cookies et mesure d'audience"
description="…"
acceptLabel="Accepter"
refuseLabel="Refuser"
policyHref="/privacy"
policyLabel="En savoir plus"
onRefuse={handleRefuse}
/>Labels are plain props: i18n stays in the host app, the package hardcodes no user-facing text.
"Manage cookies" footer link → re-prompt:
const { reopen } = useSnowCookie();
<button type="button" onClick={reopen}>Gérer les cookies</button>Two things every consumer needs, learned the hard way:
- A tracker that already loaded keeps tracking until the page dies. Unmounting the script tag is not enough — reload the page when a refusal follows an acceptance in the same session.
- Trackers can rewrite their cookies between your click-time purge and the unload (GA4 does). Re-purge on every visit where the status is
denied.
The complete recipe:
const TRACKER_COOKIE_PREFIXES = ['_ga', '_gid', '_clck', '_clsk', '_pk_'];
const { isGranted, status } = useSnowCookie();
// Latch: did a tracker run this session?
const wasGranted = useRef(false);
useEffect(() => {
if (isGranted) wasGranted.current = true;
}, [isGranted]);
// Catch cookies rewritten after the click-time purge (GA4 notably).
useEffect(() => {
if (status === 'denied') purgeCookies(TRACKER_COOKIE_PREFIXES);
}, [status]);
const handleRefuse = () => {
purgeCookies(TRACKER_COOKIE_PREFIXES);
if (wasGranted.current) window.location.reload();
};purgeCookies is best-effort by design: it expires each matching cookie against the hostname and every parent domain (the only way to delete a _ga set on .example.com).
readConsent(cookieName, version)/writeConsent(cookieName, version, 'granted' | 'denied')/clearConsent(cookieName)— pure cookie semantics, usable without React.purgeCookies(prefixes: string[])— best-effort deletion of tracker cookies by name prefix.SnowCookieProvider/useSnowCookie()→{ status, isGranted, isPending, accept, refuse, reopen }.<SnowCookie />— the banner; rendersnullunless the choice is pending. Props:description,acceptLabel,refuseLabel,title?,policyHref?,policyLabel?,onAccept?,onRefuse?,className?.
Three CSS custom properties, overridable from any scope (defaults live in var() fallbacks, so :root just works):
:root {
--snow-cookie-accent: #140b7c; /* buttons, link, icon — default: neutral #1f2937 */
--snow-cookie-accent-fg: #ffffff; /* accept button text */
--snow-cookie-z: 60;
}The banner inherits the host font (font: inherit — set your font on body). Anything else (background, radius, spacing…) is overridable with plain CSS on the flat .snow-cookie-* classes — import your override after styles.css.
The package ships built ESM/CJS + types: no transpilePackages needed. Import the CSS from _app (Pages Router) or the root layout (App Router).
Plain document.cookie with max-age + expires + SameSite=Lax — every modern browser. Known limit shared by all client-side consent banners: Safari ITP caps JS-written cookies at ~7 days, so Safari users may be re-prompted weekly (legally harmless — worst case you re-ask).
pnpm install
pnpm test
pnpm typecheck
pnpm build # dist/: index.js (ESM) + index.cjs + index.d.ts + styles.cssRelease (from main, clean tree, npm auth):
./scripts/release.sh patch|minor|major