Click-to-load consent gate for third-party embeds (YouTube, Vimeo, SoundCloud, Google Maps) plus an optional global consent modal and deferred-load hooks for Google Fonts and arbitrary callbacks. No framework. No build step. Multi-language. Single file.
The third-party iframe URL never enters the document until the visitor clicks — which is what the German "informierte Einwilligung" (and GDPR more broadly) actually requires, not a banner that loads the embed behind the scenes anyway. The same gate is extended via onConsent / googleFonts to the page-wide third-party resources (analytics, fonts, embedded forms, calendar feeds) that aren't iframes but still leak visitor data on load.
<div class="consent-embed"
data-provider="youtube"
data-embed="https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0"></div>
<script type="module">
import easyCookieConsent from 'https://cdn.jsdelivr.net/npm/@copperdesign/easy-cookie-consent@0.6.3/index.js';
easyCookieConsent({
privacyHref: '/privacy.html',
});
</script>That's the whole quickstart — paste it into any HTML file and open it. The embed renders as a styled "Load video" placeholder; click it and the iframe is built in place. If you're running a bundler, install from npm and import the package by name instead.
- Per-embed gate. Each
<div class="consent-embed">is swapped for an iframe only on user click. Until then, no request goes to the third-party host. - Optional global modal. Auto-shows until the visitor explicitly opts in to all providers at once. Two-tier dismissal: Esc / X / backdrop writes nothing (the modal can return on the next nav — the visitor didn't commit to anything), while the explicit "Not now" button writes a
sessionStorageflag so the modal stays out of the way for the rest of the visit. Both clear when the tab closes. The only choice that survives the tab is the explicit "allow all." - Per-provider remember. Each gate has an opt-in "remember this provider" checkbox. Persisted in
localStorage. - Deferred Google Fonts. Pass one or more Google Fonts URLs and the plugin injects the
<link rel="stylesheet">only after consent. Same for any other deferred work via theonConsentcallback. - i18n built in. English and German shipped; add any language by passing a
strings.<lang>table. - Zero dependencies, ~6 KB minified, one file. Install from npm, or import straight from the CDN with no build step at all.
Two ways in. Both load the same file — the published index.js — so nothing else in this README changes between them.
For a bundler (Vite, esbuild, Rollup, webpack), a Node-resolved toolchain, or a page with an import map:
npm install @copperdesign/easy-cookie-consentimport easyCookieConsent from '@copperdesign/easy-cookie-consent';
easyCookieConsent({ privacyHref: '/privacy.html' });The bare specifier @copperdesign/easy-cookie-consent resolves from node_modules/. A browser can't resolve it on its own, so this path needs either a bundler or an import map:
<script type="importmap">
{ "imports": { "@copperdesign/easy-cookie-consent": "/node_modules/@copperdesign/easy-cookie-consent/index.js" } }
</script>For a static site with no build step — the quickstart above. jsDelivr and unpkg both serve the npm package directly:
<script type="module">
import easyCookieConsent from 'https://cdn.jsdelivr.net/npm/@copperdesign/easy-cookie-consent@0.6.3/index.js';
easyCookieConsent({ privacyHref: '/privacy.html' });
</script>jsDelivr https://cdn.jsdelivr.net/npm/@copperdesign/easy-cookie-consent@0.6.3/index.js
unpkg https://unpkg.com/@copperdesign/easy-cookie-consent@0.6.3/index.js
Pin the exact version. @0.6 or an unpinned URL lets the CDN serve a newer build than the one you tested against — and this is the code deciding what loads before the visitor has agreed to anything. That's the wrong module to let float.
If you arrived here from weebly-to-firebase (the CLI that rescues a live Weebly site into a static Firebase project), this is the recommended embed-consent layer. That scaffold ships a src/js/cookie-consent.md pointer instead of a homegrown module: it deliberately drops Weebly's "single global OK" cookie banner — which loads the YouTube/Maps iframe anyway — in favour of this click-to-load gate. Install as above, then follow the wiring in that doc (or the quickstart at the top of this README). The i18n (EN/DE) and per-embed gates map directly onto the German-client sites that tool targets.
Each embed is one element on the page:
<div class="consent-embed"
data-provider="youtube"
data-embed="https://www.youtube.com/embed/<ID>?rel=0"></div>data-provider— one of the built-in providers (youtube,vimeo,soundcloud,gmaps,gsheets,gcal,betterplace,gooding,jotform,mailchimp,wufoo), or any provider you've added viaoptions.providers. (There's an eleventh,googlefonts, with no iframe behind it — see Deferred loads.)data-embed— the iframe URL to load on click. Whatever you'd normally put in<iframe src>.data-title(optional but recommended) — becomes the iframe'stitleattribute, its accessible name (WCAG 4.1.2). Describe the specific content:data-title="Interview with Jane Doe". Without it, the provider label (YouTube) is used as a generic fallback.adopt()carries a pasted iframe's existingtitleover automatically.
The gate handles the rest. The placeholder occupies the same vertical slot the iframe will take, so the page doesn't reflow on click.
A gated embed loads on consent. That is what the click is, and it settles which legal basis you're operating under:
- Art. 6 (1)(a) GDPR / DSGVO — the processing that follows the click (the visitor's IP address and User-Agent reaching YouTube, Google, SoundCloud) is justified by the visitor's consent. Not by anything else.
- § 25 (1) TDDDG (until May 2024: TTDSG, still the common citation) — the separate hook people miss. Storing information on the visitor's device, or reading information already stored there — cookies,
localStorage, whatever the embed writes — needs consent in its own right, independently of the GDPR basis for the processing that follows. This is German law implementing the ePrivacy Directive, not a universal rule. Other jurisdictions have their own equivalent — check yours.
Ungated, Art. 6 (1)(f) — legitimate interest — is what you'd reach for: the embed is part of the content, you never asked, you argue the balance falls your way. That argument exists — for the GDPR half only, and whether it survives a regulator is a different question. § 25 (1) already required consent for anything the embed stores on or reads from the device, gate or no gate, so (f) never covered the whole picture. Nothing here retroactively changes that for a site that keeps loading its embeds ungated.
It stops being available the moment you put a gate in front of the embed. You can't claim you didn't need permission while displaying a button whose entire job is to ask for it. The placeholder is the request; the click is the consent. Choosing this module is choosing (a) — write it down that way. The reasoning behind the design is in Why this exists.
onConsent and googleFonts release the third parties that aren't iframes — a Google Fonts stylesheet, an analytics tag, an embedded form helper, a calendar feed — at the same moment and for the same reason. Same basis: (a), plus § 25 (1) for whatever the deferred resource then stores on or reads from the device. Google Fonts is worth naming explicitly because it's the easiest one to overlook: served from fonts.googleapis.com it transmits the visitor's IP to Google on every pageview, and a German court has awarded damages over exactly that (LG München I, 20 January 2022, Az. 3 O 17493/20). Deferring it puts it under consent; self-hosting takes it out of the question entirely.
Consent has to be as easy to withdraw as it was to give — Art. 7 (3) GDPR. That's the obligation the revoke link and consent.reset() discharge.
A starting point, not a finished text, and not legal advice. It describes what the gate does; the providers you actually embed, their purposes, retention, and any third-country transfer are yours to add and a lawyer's to check. It assumes nothing about the global modal, so it holds in embed-only mode too.
German
Inhalte Dritter (z. B. Videos, Karten) werden auf dieser Website nicht automatisch geladen.
An ihrer Stelle steht zunächst ein Platzhalter; die Verbindung zum jeweiligen Anbieter wird
erst hergestellt, wenn Sie den betreffenden Inhalt durch einen Klick ausdrücklich freigeben.
Weitere Inhalte Dritter ohne eigenen Platzhalter (z. B. extern gehostete Schriftarten) werden
ebenfalls erst nach Ihrer Einwilligung geladen. Erst dann werden Ihre IP-Adresse und weitere
Verbindungsdaten an den jeweiligen Anbieter übertragen und gegebenenfalls Informationen auf
Ihrem Endgerät gespeichert oder ausgelesen.
Rechtsgrundlage ist Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO und § 25 Abs. 1 TDDDG
(vormals TTDSG). Die Einwilligung ist freiwillig, für die Nutzung der übrigen Inhalte dieser
Website nicht erforderlich und jederzeit mit Wirkung für die Zukunft widerrufbar:
[Einwilligung widerrufen]. Ohne Einwilligung bleiben die betreffenden Inhalte gesperrt; die
Website ist im Übrigen uneingeschränkt nutzbar.
English
Third-party content on this site (for example videos and maps) is not loaded automatically.
A placeholder is shown in its place, and the connection to the provider is established only
once you release that content with a click. Other third-party resources without a placeholder
of their own (for example externally hosted web fonts) are likewise loaded only after your
consent. Only then are your IP address and further connection data transmitted to the provider
and, where applicable, information stored on or read from your device. The legal basis is your
consent under Art. 6 (1)(a) GDPR and, in Germany, Section 25 (1) TDDDG (formerly TTDSG).
Consent is voluntary, is not required in order to use the rest
of this site, and can be withdrawn at any time with effect for the future: [withdraw consent].
Without consent the content in question stays blocked; everything else on the site remains
fully usable.
Using this module does not make a site compliant. It settles one question — which basis the embed load runs on — and leaves the rest to you.
import easyCookieConsent from '@copperdesign/easy-cookie-consent';
const consent = easyCookieConsent(options);Returns a controller object:
| Method | What it does |
|---|---|
consent.show() |
Open the modal manually. Useful for a footer "consent settings" link or for re-prompting after the visitor closed it with Esc. |
consent.optInAll() |
Writes the global opt-in, swaps in every declarative placeholder currently on the page, and fires the onLoad of any pending gate() still showing its placeholder. Same as clicking the modal's primary button. |
consent.optOutAll() |
Clears the global opt-in and writes a tab-scoped declined flag to sessionStorage, so the modal stays out of the way for the rest of the visit but returns in a fresh tab. Same as the modal's "Not now" button. |
consent.reset() |
Wipes all consent state (global plus every per-provider key). Use for a "revoke consent" link on the privacy page. Iframes already loaded on the current page stay loaded — a reload re-gates them. |
consent.teardown() |
Removes injected styles and the modal node if open. Idempotent. Use in SPAs when the host element is being unmounted. |
consent.hasConsent() |
Returns true if the visitor has granted global consent (durably, in localStorage). Use to gate code outside the plugin without poking at the storage key directly. |
consent.gate(container, { provider, onLoad }) |
Imperative consent gate. Mounts the same placeholder UI used by the iframe-swap flow into container, and on consent calls onLoad(container) instead of inserting an iframe. Use when the post-consent action is richer than dropping in <iframe src="…"> — booting the YouTube IFrame API for autoplay/loop/state callbacks, mounting an embedded form's JS, kicking off a calendar widget. See Imperative gate. |
consent.adopt(html) |
Rewrites raw third-party embed markup — a CMS-pasted <iframe>, <embed>, or <object> with no consent-embed class — into gated placeholders, and returns a DocumentFragment to insert. The third-party host is never contacted until consent. Use for client-rendered content (headless CMS, SPA, fetched HTML). See Adopting raw embeds. |
| Option | Default | Description |
|---|---|---|
privacyHref |
'#privacy' |
The privacy-policy URL shown in the modal body and each per-embed hint. When it's a real route (not a #fragment), the modal auto-suppresses while the visitor is on that page — see Suppressing the modal. |
language |
null |
null = auto-detect from <html lang>. Pass an ISO code ('en', 'de') to force. |
fallbackLanguage |
'en' |
Used when neither language nor <html lang> resolves to a built-in language. |
showModal |
true |
Whether the modal auto-shows on init. Set false to run the plugin in embed-only mode — per-embed gates still work, no global dialog is ever shown. See Embed-only mode. |
noPromptAttribute |
'data-cookie-consent-no-prompt' |
Body attribute that suppresses the modal on a single page even when showModal: true. Cheaper than maintaining a second init call. |
storagePrefix |
'cookieConsent:' |
localStorage key prefix. Change to migrate from a legacy prefix without losing visitor consent. |
colors |
(off-black / off-white palette) | Object of color tokens. See below — pass any subset; missing keys keep their default. |
embedHeights |
{ default: 300, soundcloud: 100, gmaps: 470 } |
Per-provider placeholder heights in px. Matching the iframe avoids reflow on click. Pass any subset; add <provider>: <px> for any new provider you register. |
fontStack |
'"Helvetica Neue", Helvetica, Arial, sans-serif' |
CSS font-family applied to both surfaces. Override for a typographic match with your host page. |
onConsent |
null |
Callback fired once when global consent becomes true — modal opt-in click, optInAll() call, or boot-time restoration of a prior opt-in. Use for analytics, embedded forms, calendar feeds, anything that would transmit visitor data on load. See Deferred loads. |
googleFonts |
null |
A single Google Fonts stylesheet URL, or an array of URLs. After consent, the plugin injects each as <link rel="stylesheet"> and a single preconnect to fonts.gstatic.com. Released by global consent or by the googlefonts provider's own key. See Deferred loads. |
providers |
(11 built-ins — see below) | Map of provider definitions. Pass entries to add or override. See below. |
strings |
(en, de) |
Map of localized strings. Pass entries to add or override. See below. |
easyCookieConsent({
colors: {
backdrop: 'rgba(20, 20, 20, 0.35)', // semi-transparent backdrop
surface: '#ffffff', // modal card background
text: '#111111', // primary ink
muted: 'rgba(17, 17, 17, 0.62)',
border: 'rgba(17, 17, 17, 0.14)',
accent: '#111111', // primary button fill
accentInk:'#ffffff', // primary button ink
embedSurface: '#E1E4E6', // per-embed placeholder background
},
});Any subset is fine — missing keys keep their default.
easyCookieConsent({
providers: {
spotify: {
label: 'Spotify',
operator: 'Spotify AB, Sweden',
iframeAttrs: {
frameborder: '0',
allow: 'autoplay; clipboard-write; encrypted-media',
allowfullscreen: '',
},
},
},
embedHeights: {
spotify: 152,
},
strings: {
en: {
placeholder: {
actionLabel: { spotify: 'Load track' },
},
},
de: {
placeholder: {
actionLabel: { spotify: 'Track laden' },
},
},
},
});Now <div class="consent-embed" data-provider="spotify" data-embed="…"> works.
Every iframe the plugin builds gets referrerpolicy="strict-origin-when-cross-origin" before your iframeAttrs are applied, so a new provider inherits it — as do the synthetic per-host providers adopt() registers for unrecognized hosts. It's there because some hosts refuse an embed they can't attribute to a page: YouTube answers "Video unavailable — error 153" when the request carries no usable Referer, which is exactly what a page-wide Referrer-Policy: no-referrer header or <meta name="referrer"> produces. Only the origin is sent cross-origin (https://example.com/), never the path.
Setting referrerpolicy in your own iframeAttrs overrides the baseline. Do that only when you know the host's requirement — either direction, stricter or looser, is a way to break an embed that currently works.
easyCookieConsent({
strings: {
fr: {
modal: {
title: 'Autoriser le contenu externe ?',
body: 'Ce site intègre des vidéos, de l\'audio et des cartes de tiers. …',
privacyLinkLabel: 'politique de confidentialité',
optInLabel: 'Autoriser tout le contenu externe',
optOutLabel: 'Pas maintenant',
closeLabel: 'Fermer',
},
placeholder: {
label: (p) => `Contenu externe de ${p}`,
hint: (op) => `Le chargement transfère des données à ${op}. Détails dans la `,
hintAfter: '.',
privacyLinkLabel: 'politique de confidentialité',
remember: (p) => `Toujours charger ${p}`,
actionLabel: {
youtube: 'Charger la vidéo',
vimeo: 'Charger la vidéo',
soundcloud: 'Charger l\'audio',
gmaps: 'Charger la carte',
},
},
},
},
});Strings that wrap a value (label, hint, remember) are functions so translators control word order and inflection naturally — no {{placeholder}} mini-language.
The active language is resolved at call time, in this order:
options.language— explicit override.<html lang="…">on the page — exact match first, then prefix ("en-US"→"en").options.fallbackLanguage— last resort.
The per-embed gate covers iframes. Plenty of third-party leakage isn't an iframe — Google Fonts CSS, an analytics snippet, a Wufoo form helper, a Google Calendar feed. The plugin exposes two hooks for those:
easyCookieConsent({
privacyHref: '/privacy.html',
// Convenience: Google Fonts. Pass a URL or an array of URLs.
googleFonts: 'https://fonts.googleapis.com/css?family=Inter:400,700&display=swap',
// Generic: any deferred work. Fired once when global consent becomes true.
onConsent: () => {
inject('/assets/js/vendor/wufoo.min.js');
inject('/assets/js/vendor/jquery.gcal_flow.min.js', initCalendar);
enableAnalytics();
},
});Both hooks fire when the visitor opts in via the modal, when optInAll() is called, or when a prior opt-in is restored on boot, and both fire at most once per controller instance.
They differ in what else releases them. onConsent is a global-only callback: the plugin can't know which third parties are inside it, so a per-provider remember-tick never fires it. googleFonts does have a named third party behind it — the built-in googlefonts provider — so it also loads on that provider's own key:
consent.optIn('googlefonts'); // releases the fonts, nothing else
consent.hasConsent('googlefonts');
consent.gate(el, { provider: 'googlefonts', onLoad: … }); // a "load web fonts" gateThat's the only built-in provider with no iframe behind it. It exists so a visitor can say yes to fonts without saying yes to every embed, and so the fonts show up in reset() and in the consent state under a name rather than as an anonymous side effect of the global yes.
The plugin can only defer requests that go through it. A <link rel="stylesheet" href="https://fonts.googleapis.com/..."> in your HTML, or @import url('https://fonts.googleapis.com/...'); in your CSS, fires on every pageview before any JS runs — the visitor's IP and User-Agent reach Google before the consent modal has rendered. The googleFonts option does nothing about that request because it has already happened.
Adopting googleFonts therefore means two changes:
- Remove the static
<link>/@importfrom your HTML and CSS. - Add the same URL to
googleFontsso it loads via JS, only after consent.
Visitors who haven't yet opted in see the page in your CSS font-family fallback (the next family in the stack — Helvetica, system-ui, whatever). After opt-in, the Google Fonts stylesheet swaps in and the page repaints. If that fallback flash is unacceptable, self-host the fonts instead — the plugin can't square that circle for you.
The policy half of the same move: fonts released on consent belong in your privacy policy on the same basis as the embeds — see Legal basis. Self-hosting removes the request and the disclosure together.
If you need to gate code outside onConsent — say, a button that should be hidden until consent is granted — use the controller's hasConsent():
const consent = easyCookieConsent({ /* … */ });
if (consent.hasConsent()) {
showAnalyticsToggle();
}Don't read the underlying localStorage key directly. The storage prefix and key shape are an internal detail; hasConsent() is the supported surface.
The declarative <div class="consent-embed" data-embed="…"> flow covers the most common case: drop in an iframe at the URL the visitor consented to. But sometimes the post-consent action isn't just inserting an iframe — it's booting a richer integration that the host page owns. The clearest example is the YouTube IFrame API: not "load this URL" but "instantiate a player I can call playVideo() / pauseVideo() / setLoop(true) on, and wire a state callback so the dialog auto-closes when the video ends." A static <iframe src> can't give you that.
For those cases, the controller exposes an imperative gate:
const consent = easyCookieConsent({ /* … */ });
playButton.addEventListener("click", () => {
videoDialog.showModal();
consent.gate(playerContainer, {
provider: "youtube",
onLoad: (container) => {
// The visitor has consented. Boot whatever the host page wants.
loadYouTubeIframeApi().then(() => {
new YT.Player(container, {
videoId: "dQw4w9WgXcQ",
events: {
onReady: (e) => { e.target.setLoop(true); e.target.playVideo(); },
onStateChange: (e) => { if (e.data === 0) videoDialog.close(); },
},
});
});
},
});
});What gate() handles:
- Renders the same placeholder UI the iframe-swap flow renders — same i18n strings, same "remember this provider" checkbox, same per-provider
localStoragekey. - Recognizes prior consent. If the visitor has already opted in (per-provider or globally),
onLoad(container)fires synchronously and no placeholder UI flashes. - Reacts to a later global opt-in. While a gate is still showing its placeholder, a global opt-in — the modal's "Allow all",
optInAll(), or a matchingoptIn(provider)— firesonLoad(container)automatically, exactly as it swaps in declarative iframes. The visitor doesn't have to click the gate's own button too.onLoadruns at most once per gate regardless of which path reaches it. - Adds
.consent-embed,.consent-embed--<provider>, and.consent-embed--gatedto the container so the injected CSS applies.--gateddrops the default fixed height because the host container owns sizing (a<dialog>, a flex slot, an aspect-ratio'd wrapper, etc.). - Adds
.consent-embed--loadedto the container before callingonLoad, so any custom CSS targeting the loaded state still applies.
What you handle:
- Constructing whatever DOM/state/players belong inside
containeronce consent is granted.gate()clears the container right before callingonLoad, so you're free to write into it without worrying about leftover placeholder nodes. - Sizing the container itself. The placeholder body fills the container's height; if the container has no defined height, the body collapses. Most callers already have an enclosing slot doing this (a
<dialog>, a fixed-aspect-ratio wrapper). - Re-rendering on subsequent opens.
gate()is idempotent — call it again on the same container on the next button click and it'll go straight toonLoadbecause consent is now remembered. - The referrer policy. The
referrerpolicy="strict-origin-when-cross-origin"baseline (see Adding a provider) covers iframes the plugin builds; a gated integration builds its own. If your page sets a restrictiveReferrer-Policyheader or<meta name="referrer">, setreferrerpolicyon the element yourself — with the YouTube IFrame API, that's thehost/originplayer param.
The provider id you pass scopes the consent key. Built-in ids (youtube, vimeo, gmaps, …) share state with their declarative counterparts — a visitor who clicked "remember YouTube" on a <div class="consent-embed" data-provider="youtube"> placeholder elsewhere on the site won't see the prompt again here. Custom ids (anything not in the provider registry) are accepted and get a minimal fallback label; useful when you want a private consent scope for a non-iframe integration.
The markup contract assumes you (or your templates) wrap each embed in <div class="consent-embed" …>. But a CMS editor pasting a YouTube share snippet won't know that convention — they drop in a raw iframe:
<iframe width="560" height="315" src="https://www.youtube.com/embed/Ky417-3HDKs" allowfullscreen></iframe>consent.adopt(html) rewrites that — and any <embed> / <object> — into the same gated placeholder the declarative path produces, then hands back a DocumentFragment to insert:
const consent = easyCookieConsent({ showModal: false });
// `html` is whatever the CMS field rendered to — a fetch response, an
// innerHTML string, a block of rich text.
container.replaceChildren(consent.adopt(html));The provider is resolved from the URL's host, so a pasted youtube.com/embed/… gets the YouTube label, operator, and iframe attributes automatically — no data-provider needed. An unrecognized cross-origin host is gated under a generic "external content from <host>" placeholder, so nothing third-party slips through unlabelled. The embed's width/height become an aspect-ratio on the placeholder, so the box is reserved and the swap doesn't reflow.
Insert the returned fragment — do not innerHTML it back to a string. Serializing drops the placeholder's click handler, and for any embed the visitor already consented to it would write a live src back into the page.
adopt() parses the HTML in an inert document and only ever connects the gated placeholder, so the third-party src is never fetched. That guarantee holds only for content you route through adopt() before it reaches the live page — client-rendered content: a headless CMS payload, a fetch response, anything you assign via JS.
It does not retroactively protect an embed that was already baked into the HTML the server delivered. By the time any script runs, the browser's parser has already fetched that iframe's src — cookies set, host contacted. No client-side code can un-send that request. For server-rendered embeds the rewrite has to happen upstream — in the CMS, template, or a render-time filter — before the bytes leave the server. Reach for adopt() on the client only when the markup hasn't been parsed into the live document yet.
data-consent-ignoreon a single element skips it (e.g. an embed you've already vetted).ignoreHosts: ['js.stripe.com', 'cdn.example.com']in options skips whole hosts (subdomains included) — for a cleared payment provider or your own CDN subdomain that's technically cross-origin.
Same-origin embeds and data: / blob: URLs are never gated — they contact no third party.
If you don't want the global modal at all and prefer to rely entirely on the per-embed click-to-load gates, pass showModal: false:
easyCookieConsent({ showModal: false });That's it. Per-embed placeholders still render, the inline "remember this provider" checkbox still persists to localStorage, the onConsent callback still fires if the visitor flips to global consent later via consent.optInAll() — only the auto-shown dialog is suppressed. The controller still exposes consent.show() so you can surface the modal manually from a footer link or settings page if you ever want to.
This is the right config when:
- Your site's third-party surface is only embeds (videos, maps, donation widgets), so the per-embed gate already covers everything.
- You want to keep the consent UX out of the way until the visitor actually reaches an embed.
- You're rolling your own consent UI and just want the click-to-load machinery underneath.
The privacy policy itself is handled automatically: when privacyHref points to a real route (not a #fragment) and the visitor is on that page, the modal doesn't auto-show — surfacing a consent dialog over the policy the visitor came to read (the page the modal links to) is its own UX loop. Matched on origin + pathname; only the auto-show is suppressed, so an explicit consent.show() from a "consent settings" link on that page still opens it.
For any other page where you want the modal gone — an imprint, a checkout step — add the marker attribute to that page's <body>:
<body data-cookie-consent-no-prompt>The attribute name is configurable via noPromptAttribute. Per-embed gates still work; only the auto-shown modal is suppressed for that page.
| Class | When |
|---|---|
.consent-embed |
The placeholder slot. Always present. |
.consent-embed--<provider> |
Provider-specific. Use for per-provider sizing or styling. |
.consent-embed--loaded |
Added once the iframe has swapped in — or, for gate(), once the host page's onLoad has been called. Use to strip placeholder framing in custom themes. |
.consent-embed--gated |
Added by gate() to the host container. Drops the default fixed height so the placeholder fills whatever box the host gave it. |
.consent-modal__backdrop / .consent-modal__card |
The modal. Override in your own CSS for stronger restyling than colors allows. |
The injected <style> carries data-easy-cookie-consent — handy if you want to query it from a debug console or remove it manually.
Bind the controller to window if you want an inline-HTML revoke link on your privacy page:
window.consent = easyCookieConsent({ /* options */ });<a href="#" onclick="consent.reset(); return false;">Withdraw consent</a>Or hook a regular event listener — the controller is just an object.
This link is what discharges the withdrawal obligation, Art. 7 (3) GDPR — see Legal basis. Put it where the visitor will go looking for it: the page privacyHref points to.
The dominant pattern for "consent" on the modern web is a banner that closes once you click anything, while the YouTube and Google Maps requests already fired on page load. That's not consent — it's annoyance theater wrapped around the same data flow.
The pattern this module enforces:
- No third-party request until explicit consent. The iframe URL is in
data-embed, not<iframe src>. The DOM literally cannot ping YouTube before the click. - The only durable decision is "yes." The explicit "Not now" button writes a tab-scoped flag (so the modal doesn't pester the visitor for the rest of the visit) but nothing that survives the tab — a fresh visit prompts again. Closing the modal via Esc / X / backdrop is treated as no decision at all and writes nothing. No dark-pattern "I'll just dismiss this once and it's gone forever."
- Per-embed control is always available. Even after declining, each embed has its own gate with its own opt-in. You can permit YouTube but not Google Maps without finding a settings panel.
It's also small. One file, no dependencies, no build step. Vendor it if you don't like npm.
PRs and issues welcome. See CONTRIBUTING.md for setup, the PR workflow, and what fits the scope. The repo follows the Contributor Covenant.
Quick version: fork, branch off main, exercise your change against example.html in at least one non-Chromium browser, open a PR. I (@copperdesign) review and merge.
The package is published to npm as
@copperdesign/easy-cookie-consent.
For future releases:
npm version patch # or minor / major — bumps package.json, commits, tags vX.Y.Z
git push --follow-tags
gh release create vX.Y.Z --generate-notesThe release.yml workflow handles the rest: smoke-checks the module, verifies the tag matches package.json, and publishes to npm with provenance. Requires an NPM_TOKEN repo secret minted from the copperdesign npm account.
MIT — see LICENSE.
Created by Christian Fillies.