@dimesdesign/cookie-consent
v1.0.0-beta.1
Published
GDPR cookie consent for EU websites: Google Consent Mode v2, script and iframe gating, four consent categories, themeable through CSS tokens. Zero dependencies.
Maintainers
Readme
cookie-consent
GDPR / ePrivacy cookie consent for EU websites. Built by Georgi Tsonev for reuse across projects: one script, one stylesheet, everything else is configuration.
- Google Consent Mode v2 (
consent default/update) and GTM dataLayer events - Script and iframe gating with "accept to load" placeholders for embeds
- Four categories by default (necessary · preferences · analytics · marketing), fully configurable, each with an optional cookie table
- Reject all is as easy as Accept all. No pre-ticked boxes, no dark patterns, close = reject
- Consent record with UUID, timestamp, version and method; optional server-side log (Cloudflare Worker + D1, see
worker/) - Re-consent when the policy version changes; 12-month expiry by default
- Three visual presets (
quiet,editorial,industrial), light / dark / auto, every value a--cc-*token - Texts: DOM override →
translations→ built-in EN (BG included) - Zero dependencies, ~11 KB JS + 4 KB CSS gzipped, WCAG 2.2 AA (focus trap,
role="dialog", keyboard, reduced motion)
Install
CDN (any site, Webflow included)
<!-- <head>, before GTM / gtag.js -->
<link rel="stylesheet" href="https://cookie-consent-ati.pages.dev/v1/cookie-consent.css">
<script>
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag("consent", "default", {
ad_storage: "denied", ad_user_data: "denied", ad_personalization: "denied",
analytics_storage: "denied", functionality_storage: "granted",
personalization_storage: "denied", security_storage: "granted",
wait_for_update: 500
});
window.__cc_defaults = 1;
</script>
<!-- end of <body> -->
<script src="https://cookie-consent-ati.pages.dev/v1/cookie-consent.js"></script>
<script>
CookieConsent.init({
policyVersion: "2026-09-14",
theme: "quiet",
layout: "bar",
links: { cookiePolicy: "/cookie-policy", privacyPolicy: "/privacy-policy" }
});
</script>The head snippet is only needed when GTM or gtag.js loads before the script (which is the usual case). If you skip it, the script sets the same defaults on init().
/v1/ receives non-breaking updates only. A breaking release lands in /v2/.
npm (Vite / bundlers)
npm i @dimesdesign/cookie-consentimport { init } from "@dimesdesign/cookie-consent";
import "@dimesdesign/cookie-consent/style.css";
init({ policyVersion: "2026-09-14" });Types ship with the package; every option below is documented in Config.
Gating scripts and embeds
Anything that sets non-essential cookies must not run before consent. Change type to text/plain and tag the category:
<!-- GA4 direct -->
<script type="text/plain" data-cc="analytics" async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
<script type="text/plain" data-cc="analytics">
gtag("js", new Date()); gtag("config", "G-XXXX");
</script>
<!-- Embeds: src → data-cc-src. Blocked iframes get a placeholder with an "Accept and load" button. -->
<iframe data-cc="marketing" data-cc-src="https://www.youtube-nocookie.com/embed/ID" data-cc-provider="YouTube" width="16" height="9"></iframe>
<!-- Several categories: all must be granted -->
<script type="text/plain" data-cc="analytics,marketing" src="…"></script>Elements added later (CMS lists, SPAs) are picked up by a MutationObserver.
With Google Tag Manager
Load GTM normally (it is consent-aware through Consent Mode). Every tag that sets cookies gets the built-in consent checks (Tag → Consent settings → require additional consent). On top of Consent Mode the script pushes:
| dataLayer push | When |
|---|---|
| { event: "cc_consent_update", cc_consent: { analytics: true, … }, cc_consent_id, cc_consent_method, cc_consent_version } | on init with stored consent, and on every change |
| { event: "cc_consent_analytics" } (one per granted category) | same |
Use cc_consent_update + a Data Layer Variable cc_consent.analytics as a trigger condition, or the per-category events directly.
Categories and Consent Mode mapping
Defaults:
| id | required | Consent Mode v2 types |
|---|---|---|
| necessary | yes | security_storage, functionality_storage |
| preferences | | personalization_storage |
| analytics | | analytics_storage |
| marketing | | ad_storage, ad_user_data, ad_personalization |
Override with categories: [...], adding a cookie table per category:
categories: [
{ id: "necessary", required: true, consentMode: ["security_storage", "functionality_storage"],
cookies: [{ name: "cc_consent", provider: "example.com", purpose: "Remembers your cookie choice.", duration: "12 months" }] },
{ id: "analytics", consentMode: ["analytics_storage"],
cookies: [
{ name: "_ga", provider: "Google Analytics", purpose: "Distinguishes visitors.", duration: "2 years" },
{ name: "_ga_*", provider: "Google Analytics", purpose: "Session state.", duration: "2 years" },
] },
]Cookies listed in a category are deleted when that category is rejected or withdrawn (autoClear, on by default; wildcards supported).
Texts and languages
Resolution per key: DOM override → translations[lang] → built-in locale (EN, BG). Language comes from lang in config, else <html lang>, else fallbackLang.
translations: {
en: { banner: { title: "Cookies on elica-pro.com" } },
bg: { banner: { title: "Бисквитки на elica-pro.com" } },
}For Webflow Localization, put the copy in the page as hidden elements and let Webflow translate them:
<div hidden data-cc-i18n>
<div data-cc-text="banner.title">This site uses cookies</div>
<div data-cc-text="banner.description">Strictly necessary cookies keep the site working …</div>
<a data-cc-text="links.cookiePolicy" href="/cookie-policy">Cookie Policy</a>
</div>Keys mirror the Texts type: banner.*, preferences.*, categories.<id>.title|description, links.*, placeholder.*, badge.label. Description fields accept HTML. Link elements also supply the href.
Layout, lock and themes
init({
layout: "bar", // "bar" | "card" | "modal" (default: bar, bottom)
position: "bottom", // bar: top|bottom · card: top-left|top-right|bottom-left|bottom-right · modal: center
lock: false, // backdrop + scroll lock + focus trap until a choice is made (default true for modal)
theme: "quiet", // "quiet" | "editorial" | "industrial" | your own data-cc-theme value
colorScheme: "light", // "light" | "dark" | "auto"
badge: false, // floating reopen button after a choice
showCloseButton: true, // X on the banner (non-lock only). Closing = reject.
tokens: { "--cc-color-accent": "#F58420", "--cc-font-family-heading": "'Exo 2', sans-serif" },
})Or override tokens in your own CSS:
.cc { --cc-color-accent: #F58420; --cc-color-accent-hover: #D9741C; --cc-radius: 0.25em; }All tokens (with defaults) are at the top of src/styles/cookie-consent.css: typography (--cc-font-family, --cc-title-size …), colours (--cc-color-bg/fg/muted/border/accent/backdrop …), shape (--cc-radius, --cc-shadow), layout (--cc-offset, --cc-card-width, --cc-z …) and motion (--cc-duration: 0.6s, --cc-ease). Sizes are in em, so the banner follows the host page's font size (--cc-font-size: 1em).
Reopen link anywhere on the site:
<a href="#" data-cc="open">Cookie settings</a>API
import * as CookieConsent from "@dimesdesign/cookie-consent"; // or window.CookieConsent on the CDN build
CookieConsent.init(config);
CookieConsent.show(); // banner
CookieConsent.hide();
CookieConsent.showPreferences(); // settings dialog
CookieConsent.hidePreferences();
CookieConsent.acceptAll();
CookieConsent.rejectAll();
CookieConsent.accept(["marketing"]); // grant on top of current choice
CookieConsent.setCategories({ analytics: true });
CookieConsent.getConsent(); // ConsentRecord | null
CookieConsent.isAllowed("analytics"); // boolean
CookieConsent.getCategories(); // ["necessary", …]
CookieConsent.setLang("bg"); // re-render in another language
CookieConsent.reset(); // forget the choice, show the banner again
CookieConsent.destroy();
CookieConsent.on("change", ({ consent, previous }) => {}); // also: init, show, hide, preferences:show, preferences:hide
window.addEventListener("cc:change", (e) => e.detail.consent); // same events on windowConsent record:
{ "id": "6d0c…", "ts": "2026-09-14T10:02:11.000Z", "expires": "2027-09-14T10:02:11.000Z",
"version": "2026-09-14", "lang": "en", "method": "custom",
"categories": { "necessary": true, "preferences": false, "analytics": true, "marketing": false } }Stored in the cc_consent cookie (SameSite=Lax, Secure on https, expiresDays = 365) and mirrored in localStorage for Safari's 7-day cookie cap. Set cookie.domain to share consent across subdomains.
Proof of consent (optional)
init({ logEndpoint: "https://cookie-consent-log.<you>.workers.dev/log", siteKey: "elica-pro" })Every choice is POSTed to the Worker in worker/ and stored in D1 with no IP address or user agent. Setup and export commands are in worker/README.md.
Behaviour reference
| | |
|---|---|
| First visit | banner shows (unless the path is in excludePaths), nothing non-essential loads |
| Accept all / Reject all | equal prominence; stored, Consent Mode updated, gated elements activated |
| X / Escape (non-lock) | stored as reject (closeAction: "reject", CNIL position); set "dismiss" to only hide |
| Settings | per-category switches, necessary locked on; Save / Accept all / Reject all |
| Stored choice expires | after expiresDays; banner asks again |
| policyVersion changes | stored choice discarded; banner asks again |
| Withdrawal | Consent Mode set to denied, listed cookies deleted; reloadOnWithdraw: true reloads so running scripts stop |
| GPC signal | ignored by default (respectGPC: false); when true, treated as reject-all without a banner |
| Reduced motion | transitions off |
Development
npm i
npm run dev # demo / design review on http://localhost:5180
npm test # Vitest (jsdom)
npm run test:e2e # Playwright (desktop + iPhone 13)
npm run build # dist/ (ESM + d.ts + css) and dist/cdn/v1/ (IIFE)
npm run deploy:cdn # Cloudflare Pages project "cookie-consent"
node scripts/shots.mjs # screenshot matrix into docs/shots/Docs: docs/gdpr-checklist.md · docs/webflow-integration.md · worker/README.md
MIT © Georgi Tsonev
