@ecomconsult/consentkit
v0.5.29
Published
GDPR cookie consent core with blocking engine, Shadow DOM UI and Google Consent Mode v2. Zero dependencies, no build step.
Maintainers
Readme
ConsentKit
GDPR cookie consent for the web: consent state, a blocking engine that stops trackers before they run, a Shadow DOM banner, and Google Consent Mode v2.
Most cookie banners are decoration — the trackers fire on the first frame no
matter which button you press. ConsentKit blocks at parse time: nothing but
necessary runs until the visitor says so. Dynamically injected trackers (the
official Metrika / GTM / Meta / TikTok / Hotjar snippets) and anything marked
type="text/plain" never fire a request; a plain <script src> tag written
into the HTML is prevented from executing and setting cookies, but its network
request may already be in flight — mark such tags up manually.
Vanilla ES2020, zero dependencies, no build step.
- Categories:
necessary(always on),functional,analytics,marketing - Blocking: manual markup (
type="text/plain") plus automatic interception of dynamically injected scripts. Plain<script src>tags written into the HTML (e.g. a direct GA4gtag/jstag) cannot be intercepted before the request leaves — mark those up manually; the SaaS install check points at the exact tag - UI: banner (
bar/box/modal), preferences panel, floating re-open button, light/dark, 34 locales - SSR-safe: importing on the server never touches the DOM
- Equal-weight buttons, no pre-ticked boxes — the consent invariants are fixed by design, see CONTRIBUTING.md
Status: prototype (v0.5.29). The core, the UI and the demo are verified in a browser and covered by an automated suite (
npm test); several distribution paths are not yet tested against live systems. See Project status before shipping this to production.
Не программист? Пошаговая инструкция по-русски, с картинками и разбором по кликам: INSTALL.ru.md.
Install
Four ways to add ConsentKit to a site, from simplest to most integrated.
| # | Method | Best for | Docs |
|---|---|---|---|
| 0 | Prebuilt block — copy one file from ready/ into <head>, nothing to install | Tilda and other site builders; no developer needed | ready/README.md |
| 1 | Script tags — copy src/ to your server, three <script> tags in <head> | Any site you control | Quickstart below |
| 2 | npm — npm install @ecomconsult/consentkit | Bundled apps, React | Quickstart below |
| 3 | WordPress plugin — copy the plugin folder to wp-content/plugins/; rewrites static tracker tags server-side | WordPress / WooCommerce | plugins/wordpress/consentkit/ |
| 4 | Google Tag Manager — import the container, trigger tags on consent events | Sites already running GTM | integrations/gtm/README.md |
npm install @ecomconsult/consentkitOr drop the files in directly — no bundler required.
Site builders that will not let you upload files (free Tilda and similar) need a
single self-contained <script> block instead; the repository ships a generator
for that, and loading ConsentKit from a third-party CDN is deliberately not
recommended — the CDN would receive the visitor's IP before any consent exists.
Quickstart — script tags
Load order is contractual. ck-core.js starts blocking at parse time, so it
must come first and should not be deferred.
<script src="/consentkit/src/ck-core.js"></script>
<script src="/consentkit/src/ck-locales.js"></script><!-- optional: extra languages -->
<script src="/consentkit/src/ck-ui-branding.js"></script><!-- optional: logo / attribution -->
<script src="/consentkit/src/ck-ui.js"></script>
<script>
ConsentKit.init({
policyVersion: '1',
language: 'auto',
layout: { type: 'box', position: 'bottom-left' },
theme: { accent: '#2B50D8', mode: 'auto' }
});
</script>Quickstart — npm
The main entry is a side-effect import: it loads the core, the locales and the UI, then re-exports the API.
import ConsentKit from '@ecomconsult/consentkit';
ConsentKit.init({
policyVersion: '1',
layout: { type: 'bar', position: 'bottom' },
theme: { accent: '#2B50D8', mode: 'auto' }
});
if (ConsentKit.allowed('analytics')) {
// start analytics
}CommonJS works too:
const ConsentKit = require('@ecomconsult/consentkit');
ConsentKit.init({ policyVersion: '1' });Named exports are available alongside the default:
import { init, allowed, getState, accept, rejectAll, withdraw, show } from '@ecomconsult/consentkit';Core without the UI
@ecomconsult/consentkit/core loads the consent engine and blocking only — no banner, no
locales. Use it when you ship your own interface.
import ConsentKit from '@ecomconsult/consentkit/core';
ConsentKit.init({ policyVersion: '1' });
ConsentKit.accept({ analytics: true, marketing: false });Import it once
@ecomconsult/consentkit is a side-effect module and the core is a singleton on the global
object. Import it at your entry point; importing it again elsewhere is harmless
but does not create a second instance.
React
react is an optional peer dependency (>=17) — install it yourself.
useConsent() subscribes to the event bus and unsubscribes on unmount.
import '@ecomconsult/consentkit'; // side effect: core + locales + UI
import { useConsent } from '@ecomconsult/consentkit/react';
function CookieStatus() {
const { state, allowed, accept, rejectAll, withdraw, show } = useConsent();
if (!state.decided) return <p>Waiting for a choice…</p>;
return (
<div>
<p>Analytics: {allowed('analytics') ? 'on' : 'off'}</p>
<button onClick={() => accept('all')}>Accept all</button>
<button onClick={() => accept({ analytics: true })}>Analytics only</button>
<button onClick={rejectAll}>Reject all</button>
<button onClick={withdraw}>Withdraw consent</button>
<button onClick={show}>Cookie settings</button>
</div>
);
}Only mount the analytics-dependent part once consent exists:
function Analytics() {
const { allowed } = useConsent();
if (!allowed('analytics')) return null;
return <Tracker />;
}Server rendering
useConsent() returns decided: false, all opt-in categories false, and
no-op actions on the server, then re-renders with the real state after
hydration. Guard on state.decided rather than assuming a value on first paint.
Configuration
Pass any subset to init(). Nested objects merge with the defaults.
| Key | Type | Default | Notes |
|---|---|---|---|
| policyVersion | string \| number | "1" | Bump to invalidate stored consent and re-show the banner |
| language | string | "auto" | "auto" reads navigator.language; "page" (v0.5.21) reads the page — <html lang>, then the first path segment, then og:locale, then the browser (v0.5.24); or a fixed code. Falls back pt-BR → pt → en — see Banner language |
| layout.type | "bar" \| "modal" \| "box" | "bar" | box is a corner card, up to 540px wide |
| layout.position | string | per type | bar: bottom (default) / top. box: bottom-left (default) / bottom-right. modal is always centred. A position that does not belong to the chosen type falls back to that type's default; the type itself is unaffected |
| theme.accent | string | "#2B50D8" | Exposed as --ck-accent |
| theme.font | "inherit" \| "system" | "inherit" | v0.5.0. inherit takes the host page's font family; system restores the pre-0.5.0 system stack. Font sizes are fixed either way |
| theme.radius | { card, button } | { card: 16, button: 8 } | v0.5.0. px, clamped 0–32. A bare string or number is the pre-0.5.0 form and still sets the card radius |
| theme.buttons | { accept, reject, settings } | see below | v0.5.0. Per-button appearance. A colour you set is painted as set; contrast rules correct only derived colours (v0.5.10) — see Button appearance |
| theme.mode | "auto" \| "light" \| "dark" | "auto" | auto follows prefers-color-scheme |
| theme.dark | { bg, ink, accent, onAccent } | built-in | Overrides the dark palette |
| theme.light | { onAccent } | built-in | v0.5.10. The light mirror of theme.dark. An onAccent set here is the filled buttons' text colour for light mode, painted as given |
| texts.policyUrl | string | — | v0.5.0. Cookie policy address. http(s) only; anything else is ignored |
| texts.policyUrls | object | — | v0.5.26. The policy address per language: { ru: "https://shop.md/ru/privacy", ro: "https://shop.md/ro/politica" }. Resolved policyUrls[<lang>] → two-letter base → policyUrl; http(s) only, and a value that is not falls through to the next candidate. policyUrl stays the default, so an older client ignores this key — see Custom texts and links |
| texts.detailsAction | "policy" \| "settings" \| "hide" \| "declaration" | see notes | v0.5.0, declaration in v0.5.7. What «Learn more» does. Defaults to policy when policyUrl is set, settings when it is not. policy or declaration without a usable URL falls back to settings rather than rendering a dead link |
| texts.declarationUrl | string | — | v0.5.7. Address of the cookie declaration page, used by detailsAction: "declaration". http(s) only. Filled by the hosted service; the client only reads it. A single string — since v0.5.26 the page is opened with ?lang=<banner language> instead of a second address |
| texts.<lang> | object | — | v0.5.15. Per-language dictionary overrides, keyed by a language tag (ru, ro, en, pt-br, …). Overridable keys: bannerTitle, bannerText, panelTitle, panelIntro, extraTitle, extraText, and cat.<necessary\|functional\|analytics\|marketing>.title / .desc. An empty string falls through to the standard text; every other key is ignored — see Custom texts and links |
| texts.links | object[] | [] | v0.5.15. Up to 3 links under the banner buttons and at the foot of the settings panel: { id, url, urls?, label: { ru, ro, en, … } }. url is http(s) only and required; a row with no resolvable label or an unusable address is skipped. v0.5.26 adds the optional urls — { <lang>: <http(s) url> } beside url, resolved urls[<lang>] → two-letter base → url, so a bilingual site can send each language to its own page. Does not affect detailsAction, except that «Learn more» is hidden when its policy / declaration URL repeats one of these links — see Custom texts and links |
| categories.*.enabled | boolean | true | Per category: functional, analytics, marketing. Hides the toggle when false |
| consentTtlDays | number | 365 | Lifetime of the stored decision |
| integrations.gcm | boolean | true | Google Consent Mode v2: push consent update after each choice. false only when the site runs Consent Mode itself — the parse-time all-denied default is pushed regardless, so a site with false and no update of its own stays denied for Google forever |
| integrations.gtmDataLayer | boolean | true | Push consent events to window.dataLayer |
| blocking.mode | "known" \| "strict" | "known" | strict also holds back unknown third-party scripts and iframes — see Strict mode |
| blocking.allow | string[] | [] | Hosts strict mode must never intercept. Matched by suffix, so partner.com also covers cdn.partner.com |
| blocking.placeholders | boolean | true | v0.5.7. Draw a card in place of an embed held back before consent — see Placeholders for blocked embeds. false restores the pre-0.5.7 behaviour: the frame is still blocked, just invisible |
| consent.shareSubdomains | boolean | false | v0.5.17. Write the consent cookie on the registrable domain, so shop.example.com and blog.example.com share one decision — see One consent across domains |
| consent.linkedDomains | string[] | [] | v0.5.17. Up to 10 hosts (no scheme) that belong to you. A click on a link to one of them carries the visitor's decision across in the URL fragment — see One consent across domains |
| geo.mode | "all" \| "list" | "all" | v0.5.17. list shows the banner only to visitors from geo.countries; everyone else gets an unsaved all-granted page load — see Geo rules |
| geo.countries | string[] | [] | v0.5.17. ISO-3166-1 alpha-2 codes, matched case-insensitively. Only read when geo.mode is "list" |
| hostdb | Record<string, Category> | — | Extra host: category pairs merged into the tracker database, applied before the initial scan. SaaS mode fills this from the service; ConsentKit._extendHostDb() does the same at any later point |
| cookieTable | CkCookieTableEntry[] | [] | Declared cookies, listed per category in the panel. v0.5.16: purpose may be a per-language object and expiryDays a number of days — see below |
| services | CkService[] | [] | v0.5.8. Third-party services the site declares. Each gets its own toggle inside its category group in the panel, and can be refused individually — see Services. At most 50 |
| branding | object | absent | v0.3.5. The attribution line (and optional logo) at the foot of the banner, rendered by src/ck-ui-branding.js. Absent from the defaults: omit the key and nothing renders — see Branding |
cookieTable entries:
{ name: '_ga', category: 'analytics', vendor: 'Google', purpose: 'Visit statistics', expiry: '2 years' }Since v0.5.16 two fields on that row can carry the visitor's language instead of one fixed string:
{
name: '_ga',
category: 'analytics',
vendor: 'Google',
// A string still works exactly as before. An object is resolved per language.
purpose: { ru: 'Статистика посещений', ro: 'Statistici de vizitare', en: 'Visit statistics' },
// Days, as a number. `null` or `0` means a session cookie.
expiryDays: 730,
// Kept for clients older than 0.5.16; ignored when `expiryDays` is present.
expiry: '2 года'
}purpose: string | { ru?, ro?, en?, <lang>? }. An object is resolved with the same chainbranding.poweredBy.textsandtexts.links[].labeluse, plus one extra step: banner language → its two-letter base (pt-BR→pt) →en→ the first non-empty value in the object. That last step is why a cookie row differs from a link: a link with no label for this language is skipped, but a cookie must still be declared, so the operator's own words are shown in whatever language they exist rather than nothing at all. A plain string renders as it always did; a number, an array or an object that is empty in every language renders an empty cell (—).expiryDays?: number | null. A whole number of days.nullor0renders the localised word for a session cookie (сессия/sesiune/session); a positive number renders localised plural forms (2 дн./2 zile/2 days). WhenexpiryDaysis absent the oldexpirystring is shown; when both are presentexpiryDayswins. Anything that is not a whole number ≥ 0 is treated as absent.
A row written before 0.5.16 renders as it did, with one deliberate exception: a
cell that resolves to nothing now shows the same — an absent field has always
shown. So purpose: '', expiry: '' and a non-string expiry (a bare number,
say) draw an em dash where 0.5.15 drew a blank or a raw value.
Both keys are translated in all 34 languages. The hosted service writes
purpose objects and expiryDays into generated configs from 0.5.16 onwards,
and keeps writing expiry as well so that an older inline copy of the client
still shows something.
Banner language
language takes one of three values:
"auto"(default) — the visitor's browser, fromnavigator.language."page"(v0.5.21) — the page decides. This is the mode for a site with real per-language URLs (/ru/,/ro/): a Romanian page then greets a visitor with a Russian browser in Romanian.- a language code —
"de","pt-BR", that language always.
What "page" reads, in order (v0.5.24):
<html lang>— the explicit declaration, and it always wins.- the first path segment, when it names a language —
/ru/…,/ro,/en/about. Only a plain two-letter code (or one the locale table carries verbatim, likept-br) counts, so/ruby/is not Russian and/engineering/is not English. <meta property="og:locale">—ro_RO→ro.navigator.language.en.
Every step is checked against the locales the build actually ships, so a source
naming a language this build has no locale for is no answer at all and falls
through to the next source — never straight to English. A page at /xx/
with og:locale ro_RO resolves to Romanian.
Give each page an <html lang> anyway. Steps 2 and 3 are a safety net for
sites that cannot — they exist because real sites switch language by path and
ship a bare <html> — but the attribute is the one signal that is unambiguous,
costs nothing, and is read first by ConsentKit, by screen readers and by search
engines alike.
"page" follows a lang that changes later (v0.5.24). An app that sets
document.documentElement.lang from its own language switch after the banner
has rendered — a single-page app, typically — gets a banner that rebuilds in the
new language, with the settings panel reopened in it if it was open. Only in
"page" mode; "auto" and a fixed code install no observer at all.
In every case the lookup falls back pt-BR → pt → en, and the legacy
Moldovan tag mo is read as ro — on the path (/mo/) as well as everywhere
else.
"auto" deliberately does not look at <html lang>. On builder-made sites
that attribute is routinely wrong — Tilda, for instance, writes one template
lang onto every page, so a Romanian page announces itself as Russian — and
teaching auto to read it would silently change the banner on every site
already running, including the many where it is correct today. That is why
page is a third mode rather than a new meaning for the old one.
"page"needs client 0.5.21 or newer. An older client does not recognise it as a mode, reads it as a language code, matches no locale and renders English.
Services
v0.5.8. A services row names one third party: where its resources come
from, which cookies it sets and what it is for. The preferences panel lists it
inside its category group with its own toggle, so a visitor can accept
analytics in general and still refuse one particular service.
services: [{
id: 'hotjar', // stable, ^[a-z0-9-]{1,64}$
name: 'Hotjar',
vendor: 'Hotjar Ltd',
category: 'analytics',
hosts: ['hotjar.com'], // suffix-matched, like the tracker database
paths: ['/hotjar-'], // optional, substring-matched
cookies: ['_hjSession', '_hjSessionUser'], // names; matched against cookieTable
privacyUrl: 'https://www.hotjar.com/privacy/', // http(s) only
purpose: { ru: '…', ro: '…', en: 'Records how visitors move around the page.' },
enabled: true // false: not shown, not blocked separately
}]How the list is laid out (v0.5.12). A group's «N services · M cookies» line is a disclosure button, and it is collapsed by default — eight services no longer push the switches the visitor came for off the screen. Opening it reveals the group's services and, at the end, the group's own «Which cookies (N)» table. Each service is one line: name, vendor, and its switch on the right. Everything else — the purpose, the privacy link, «Cookies it sets (N)» — lives behind that row's own small «Details» disclosure. Both controls are real focusable controls with a visible focus ring, and both are translated in ru, ro and en (every other language falls back to en, as elsewhere).
A group with no services is unchanged from 0.5.7: no counter, no disclosure, and its cookie table sits at the top level where it always did.
A service in the necessary group gets no switch (v0.5.12). That group
cannot be refused, so a control that could only ever sit at «off» would be
telling the visitor something untrue — the «always on» badge is rendered in its
place. allowedService(id) answers true for such a service unconditionally,
and a refusal for one is stripped from the denial map on every read and write,
including a record written by hand or by an older client.
What the toggle does:
- Group off — every service of that group is off and blocked, as before.
- Group on — the services come back, except the ones the visitor switched off by hand. A refusal survives the group being switched off and on again.
- A refused service's resources are held back exactly as if its category had no consent, and its cookies are deleted exactly as on a category withdrawal.
- The «Allow and show» button on a blocked embed's placeholder grants the category and clears the refusal on that frame's service.
hosts need not already be in the tracker database: init() folds them into
the block map under the row's own category, so a service host ConsentKit has
never heard of is still held back.
Refusals are stored in ck_consent as services: { '<id>': false } — denials
only. An id absent from the map is allowed, subject to its category. A
necessary service is never in the map.
Button appearance
Each of the three banner buttons can be styled independently:
theme: {
accent: '#2B50D8',
font: 'inherit',
radius: { card: 16, button: 8 },
buttons: {
accept: { variant: 'filled' }, // accent fill
reject: { variant: 'filled' }, // always matches accept
settings: { variant: 'outline', borderWidth: 1 } // accent border
}
}Each entry takes variant ("filled" or "outline"), and optionally bg,
fg, border and borderWidth (1 or 2). Omitted colours come from
theme.accent.
Accept and reject are always equal. They render at the same size and weight,
and they always share one variant — if the two disagree in the config,
accept.variant is used for both. A reject button that looks weaker than
accept is a dark pattern, and consent collected through one is not freely
given, so the config simply cannot express it. settings is independent and
may itself be filled.
A colour you set is painted as you set it; contrast rules decide only the
colours you left to us. Since v0.5.10 the 4.5:1 text rule and the 3:1
border rule apply to derived values — the automatic text on a filled button,
the border and text an outline button takes from theme.accent, the link
colour read off the accent. A fg, border or onAccent you wrote yourself
is never repainted, however low it measures: the debug panel reports the ratio
and warns that it is below the recommended floor (for example, "contrast 4.32 —
below the recommended 4.5"), and the choice stays yours. Concretely:
- a
fgyou set is painted as set and only measured against the fill behind it; the derived text on a filled button still becomes white or#161616, whichever contrasts more, when it would fall under 4.5:1; - a
borderyou set on anoutlinebutton is painted as set; a border derived fromtheme.accentis darkened (light card) or lightened (dark card) in small steps until it clears 3:1 against the card, so a default theme can never produce a button invisible against its own card; - an outline button's text, when you did not set one, is that resolved border colour put through the same 4.5:1 rule — derived even when the border it came from was yours.
The card is #ffffff in light mode and #1c1c1e in dark. A colour the
arithmetic cannot read — a CSS colour name, an rgb() string — is left exactly
as you wrote it rather than being silently replaced.
The preferences panel follows the banner, with no settings of its own
(v0.5.11): the panel's «save choice» button is styled as accept, the panel's
own accept and reject as settings, and the floating button takes the
accept button's fill
(its border colour when accept is outline), while the category and service
switches keep theme.accent.
The same arithmetic is exposed as pure functions on ConsentKit._contrast
(relativeLuminance, contrastRatio, ensureContrast, stepToContrast,
resolveButtonStyles, resolveRadius, resolveFont, resolveDetails,
buildThemeCss) so a theme editor can show the same numbers the banner paints
instead of reimplementing them. It is present whenever src/ck-ui.js is loaded,
and it is safe to call in Node — nothing in it touches the DOM. The debug
panel's Appearance section reads it directly and reports each button's
resolved colours, its contrast ratio, and either that a derived value was
adjusted or that a value you set measures below the recommended floor. Each
resolved record carries ratio and adjusted, plus low (painted text under
4.5:1) and, for outline buttons, borderRatio, borderAdjusted and
borderLow (border under 3:1).
The «Learn more» link
texts.detailsAction decides what the link at the end of the banner copy does:
| Value | Renders |
|---|---|
| "policy" | A link to texts.policyUrl, opened with target="_blank" rel="noopener" |
| "settings" | A button that opens the preferences panel |
| "hide" | Nothing at all |
| "declaration" | v0.5.7. A link to texts.declarationUrl — the cookie declaration page — opened the same way as policy |
The default follows policyUrl: policy when one is set, settings when it
is not — so supplying only a URL does the obvious thing. declarationUrl
deliberately does not affect that default: a site that gains a declaration
address keeps whatever «Learn more» already did until it asks for the change.
Since v0.5.26 policy resolves its address through
texts.policyUrls when one is present —
policyUrls[<lang>], then the two-letter base, then policyUrl — so a
bilingual site sends each banner to its own policy page. The map alone is
enough in the languages it covers; the ones it does not still fall back to
policyUrl, or degrade to settings when there is none, exactly as before.
declaration needs no second address. The cookie declaration page is ours and
reads ?lang=ro|ru|en, so since v0.5.26 the banner appends its own language
to it — ?lang= or &lang= as the address demands, replacing any lang that
is already there rather than adding a second one, and always before a #
fragment. Before this a Romanian banner opened a Russian table of cookies,
because the page fell back to the visitor's browser language. The rewrite is
deliberately narrow: it touches only the address declarationUrl itself names
— including a link row you pasted it into, so both copies still count as one
page for the duplicate rule below — and never an address of yours.
Both link forms accept http(s) addresses only. A javascript: or data: URL
in a control the visitor is invited to click is an XSS vector, so anything else
is refused and the link degrades to settings.
Since 0.5.15 both link forms are also dropped for a render whose texts.links
already show the same address, so the banner never links one page twice — see
texts.links.
Before 0.5.0 this control was rendered as
<a href="#">with no handler at all: clicking it jumped to the top of the page and nothing else. Any site running 0.4.x or earlier has a dead «Learn more» link.
Custom texts and links
Since 0.5.15 the banner and the panel can carry your own words, per language, and up to three of your own links. This exists because a cookie banner in some jurisdictions has to name the operator and say where a data subject may complain — that is a legal requirement, not decoration, and it does not fit in any of the built-in sentences.
texts.<lang> — dictionary overrides
A key under texts is treated as a language dictionary when — and only when —
it looks like a language tag, matching /^[a-z]{2}(-[a-z]{2})?$/. That is what
keeps policyUrl, detailsAction, declarationUrl and links out of it; no
scalar setting under texts is ever two letters.
Overridable keys, and nothing else:
| Key | Where it shows |
|---|---|
| bannerTitle | The banner's heading |
| bannerText | The banner's paragraph |
| panelTitle | The settings panel's heading |
| panelIntro | The line under it |
| extraTitle | Heading of the «Additional information» block (defaults to a translated «Additional information» in all 32 languages) |
| extraText | Body of that block. Empty in every dictionary — the block renders only when you supply text |
| cat.<name>.title / .desc | One category's name and description, for necessary, functional, analytics, marketing |
Anything else — acceptAll, more, save, an unknown key — is ignored. The
button labels are what a visitor recognises across sites, and the plural tables
are arrays that a string override would break.
Values are merged in three layers, in this order, each winning over the one before it:
built-in dictionary ← window.__ckLocales ← config.texts[lang]For the last layer the exact resolved code is tried first, then its two-letter
base: a banner that resolved to pt-br reads texts['pt-br'], then
texts['pt']. A non-empty string wins; an empty string, a missing key or a
non-string falls through to the layer below. That is what makes an empty field
in an editor mean «use the standard text» rather than «show nothing».
Every value is treated as text, never as HTML, everywhere.
The markup subset for extraText
extraText is the one field with structure, because an operator block genuinely
is two or three paragraphs with an address and a link in them. The rules below
are the whole contract — the hosted service's validator mirrors them exactly,
so what the cabinet previews is what the banner paints:
- Paragraphs. A blank line (two newlines) starts a new paragraph. Each
paragraph becomes one
<p>. - Line breaks. A single newline inside a paragraph becomes a
<br>. - Three passes, in this exact order. The order is part of the contract,
not an implementation detail:
[label](url)links. Tokenised first, and their pieces are never seen by the later passes. This is what stops the bare-URL rule from eating the address inside[label](https://…), stops the bare-e-mail rule from firing inside amailto:label, and leaves a**inside a link label literal.**bold**, over the text between those links.- Bare addresses, inside each bold and each plain span: a bare
https://orhttp://URL, and a bare e-mail address, each becoming its own label. Bold must come before the bare addresses, not after:**[email protected]**is one bold run that happens to contain an address, and linking the address first would split the run and leave the asterisks visible on screen.
- What the passes produce. Every link —
[label](https://…),[label](mailto:…), a bare URL, a bare e-mail — is rendered withtarget="_blank" rel="noopener".**bold**becomes<strong>, and an address inside it is bold and clickable. There is no nesting the other way:**inside a[label](…)stays literal, because pass 1 removed the whole link before pass 2 ran. An unclosed**is two literal asterisks, never a bold tail that swallows the paragraph. - Any other scheme is not a link.
[x](javascript:…),data:,file:— the whole literal[x](javascript:…)is rendered as plain text, so a mistake is visible to whoever wrote it rather than silently swallowed. - No HTML.
<b>is four characters of text. Nothing in this path goes throughinnerHTML; the block is built withcreateElementandcreateTextNodeonly. The hosted service refuses<in these fields outright, at validation time.
The block is drawn after the categories and their service lists, before the
panel's buttons, and only when extraText resolves to a non-empty string.
texts.links — your own links
Up to three, rendered as a row under the banner's buttons (in all three layouts) and at the foot of the settings panel:
texts: {
links: [
{ id: 'privacy', url: 'https://shop.md/privacy',
label: { ru: 'Политика конфиденциальности', ro: 'Politica de confidențialitate', en: 'Privacy policy' } },
{ id: 'cookies', url: 'https://shop.md/cookies',
label: { ru: 'Политика cookie', ro: 'Politica cookie', en: 'Cookie policy' } }
]
}url must be http(s); anything else is skipped. label is resolved with the
same fallback chain as branding.poweredBy.texts — exact code, then the
two-letter base, then en — and a row whose label resolves to nothing is
skipped rather than rendered blank. The cap of three is applied to the rows that
survive those checks, so one malformed entry never costs a good one its place.
urls — a different address per language (v0.5.26)
A bilingual site publishes its policy twice, at /ro/politica and at
/ru/politika. Before 0.5.26 a link row carried one url with a label per
language, so the Romanian banner printed a Romanian label on the Russian page —
the label was translated, the destination was not. The optional urls map fixes
that:
texts: {
links: [
{ id: 'privacy',
url: 'https://shop.md/privacy',
urls: { ru: 'https://shop.md/ru/privacy', ro: 'https://shop.md/ro/politica' },
label: { ru: 'Политика конфиденциальности', ro: 'Politica de confidențialitate', en: 'Privacy policy' } }
]
}The chain is three steps and stops: urls[<lang>], then urls[<two-letter
base>] (ro-RO → ro), then url. It deliberately does not end at en
the way label does — there is always a usable address, because url is
required, and sending a German visitor to the English page would be a worse
answer than your own default. Keys are matched lowercased, and a value that is
not http(s) is ignored and the next candidate tried, so one typo never costs
the other language its address.
url stays required and stays the default. A row without a usable url is
skipped even when urls would have answered — which is also the compatibility
rule: an older client copy, inlined on a page or pasted into WordPress, ignores
urls it has never heard of and keeps sending everyone to url. Nothing about
url changed type or meaning.
This does not touch detailsAction, with one exception that keeps the banner
from printing the same address twice: when detailsAction resolves to policy
or declaration and its URL matches one of the links on screen — compared
trimmed, with a case-insensitive host and any trailing slash ignored — the
in-text «Learn more» link is not rendered and the banner behaves as
detailsAction: "hide" for that render, leaving the address to the link row,
which also carries your own label for it. An explicit detailsAction:
"settings" is unaffected: it opens the panel and has no address to duplicate.
A worked example
{
"language": "auto",
"texts": {
"policyUrl": "https://shop.md/privacy",
"ru": {
"bannerTitle": "Cookie на сайте INTERSTEPCOM",
"extraText": "Оператор: «FIRM» SRL, IDNO 1234567890123, мун. Кишинёв, ул. Примерная 1.\n\nПо вопросам обработки персональных данных пишите на **[email protected]** — отвечаем не позднее одного месяца (ст. 12 ч. (3) Закона № 195/2024).\n\nВы вправе подать жалобу в [Национальный центр по защите персональных данных](https://datepersonale.md)."
},
"links": [
{ "id": "privacy", "url": "https://shop.md/privacy",
"label": { "ru": "Политика конфиденциальности", "ro": "Politica de confidențialitate" } },
{ "id": "cookies", "url": "https://shop.md/cookies",
"label": { "ru": "Политика cookie", "ro": "Politica cookie" } }
]
}
}A Russian visitor sees the custom banner title, and a panel whose «Дополнительно»
card carries three paragraphs: the operator's details, a bold auto-linked
address with the statutory answering period, and a link to the supervisory
authority. A Romanian visitor sees the standard Romanian banner title (nothing
was overridden for ro), no «Informații suplimentare» card (no ro.extraText),
and both links under the buttons in Romanian.
Reopening the settings
ConsentKit.openSettings() opens the preferences panel from anywhere on the
page — a footer link, a menu item, a button in your own cookie policy. It is
safe to call before the banner has mounted: a call that arrives while the UI
file is still loading is remembered and honoured on mount, so a link clicked
during a slow page load still works.
The same panel also has an address. Any link to a page of the site ending in
#ck-settings opens the preferences panel — both when the page loads with that
fragment and when the link is followed on an already-open page. The fragment is
then removed from the address with history.replaceState, so a reload or a
«back» does not reopen the panel. This is the address the «change your cookie
choice» button on a cookie declaration page points at, and the one to put in a
site footer:
<a href="#ck-settings">Change your cookie choice</a>Placeholders for blocked embeds
When the engine holds back an <iframe> before consent — a known tracker, or
any third-party frame in strict mode — the visitor would otherwise see an empty
hole where a video or a map should be. Since 0.5.7 ConsentKit draws a card in
its place: the name of the service, the category the embed is waiting for, a
primary button ("Allow and show") that grants that one category and
loads the embed, and a link to the full settings panel.
The card is sized from the frame's own width/height (or its computed size),
never shorter than 120px, and never wider than its container. It is rendered in
its own Shadow DOM and takes the banner's theme — the page's font, your accent
colour and corner radius — so it looks like part of the site rather than part of
a third-party widget. Strings ship in ru, ro and en; every other language falls
back to en.
The button grants one category through the ordinary consent path: the decision
is stored and journalled as method: 'custom', the usual ck:consent /
ck:change events fire, and consent the visitor had already given to other
categories is preserved rather than overwritten. The frame itself is restored by
the core's normal revival pass, which is the same code path the panel's switches
and «Accept all» already use.
Frames that are display:none, 1×1 tracking pixels, or outside <body> are
left alone, and a frame the site allowed never gets a card at all — an allowed
frame is never intercepted in the first place. Set blocking.placeholders:
false to restore the pre-0.5.7 behaviour.
Infrastructure
Some third-party hosts are not a consent decision at all: they are where a site
builder or hosting platform serves the site's own markup, styles and
scripts from. A Tilda page loads its layout from tildacdn.com, a Wix page
loads its from parastorage.com, and a page using Google Fonts loads its
typefaces from fonts.gstatic.com. ConsentKit ships these as a separate class
of host — readable as ConsentKit._infra(), tested per URL or hostname with
ConsentKit._isInfra(url) — covering the CDNs of Tilda, Wix, Shopify,
Squarespace and Webflow, the general asset CDNs (cdn.jsdelivr.net,
unpkg.com, cdnjs.cloudflare.com, code.jquery.com, ajax.googleapis.com),
Google Fonts, hCaptcha and the endpoint a Google-hosted frame posts its
Content-Security-Policy violation reports to (csp.withgoogle.com — a policy
report carries no visitor, so there is nothing to consent to). Strict mode never
intercepts them, because blocking a
builder's own CDN breaks the page without protecting anyone; the hosted service
also leaves them out of scan reports, since there is nothing for a site owner to
decide. Membership is a claim that a host delivers the site's own assets, not
that it is harmless in general — anything that measures keeps a real consent
category instead, which is why cloudflareinsights.com (Cloudflare Web
Analytics) is classified as analytics and blocked before consent even though
the rest of Cloudflare's CDN is infrastructure. The list holds 56 entries.
Both lists are matched by suffix and returned as copies, so reading them cannot
widen what strict mode allows.
API
All methods are safe to call at any time and never throw.
| Method | Returns | Description |
|---|---|---|
| init(config?) | CkState | Idempotent. Restores stored consent, then dispatches ck:init. Calling again merges config only |
| allowed(category) | boolean | necessary is always true |
| allowedService(id) | boolean | v0.5.8. May this one declared service run? True when its category is granted and the visitor has not refused it individually. An id the config does not declare is true |
| getState() | CkState | A fresh object on every call |
| accept('all') | CkState | Grants everything. method: 'accept_all' |
| accept({ ... }) | CkState | Per-category choice. method: 'custom'. Omitted categories stay denied. v0.5.8: an optional services: { '<id>': false } replaces the stored refusals wholesale; omit it to leave them untouched |
| rejectAll() | CkState | Denies every opt-in category. method: 'reject_all' |
| withdraw() | CkState | Clears storage and known cookies, sends GCM denied, resets to decided: false |
| show() | void | Opens the preferences panel |
| openSettings() | void | v0.5.7. Opens the preferences panel. Safe before the UI has loaded — the request is remembered and honoured as soon as the banner mounts |
| hide() | void | Closes the panel |
| config | CkConfig | The merged, effective config |
| version | string | Core version string |
State
{
decided: false, // false until the visitor chooses — the banner shows while false
id: null, // uuid of the stored decision
ts: null, // ISO timestamp
policyVersion: '1',
categories: { necessary: true, functional: false, analytics: false, marketing: false },
services: {}, // v0.5.8. Per-service refusals ONLY: { hotjar: false }
method: null // 'accept_all' | 'reject_all' | 'custom' | 'linked' | 'geo'
}Already-loaded scripts are not unloaded by withdraw() — cookies are cleared
and the next page load is clean.
Introspection
Members prefixed with _ are not private-by-convention placeholders: they
are a deliberate read-only surface for tooling — the debug panel, the hosted
cabinet's theme editor, and tests — and they are documented because those
consumers depend on them. They are stable within a minor version, and every one
of them returns a copy, so reading can never widen what the engine allows.
| Member | Returns | Description |
|---|---|---|
| _blocked() | array | What the engine is currently holding back, plus a sweep of blocked markup. Drives the debug panel's list |
| _categoryForUrl(url) | string \| null | The category the database gives a URL — the same lookup the engine uses |
| _categories | string[] | The four category names, as a copy |
| _services() | array | The normalised service rows from the config |
| _serviceForUrl(url) | object \| null | Which declared service a URL belongs to |
| _deniedServices() | string[] | Ids the visitor refused individually |
| _extendHostDb(map) | number | Merge extra host: category pairs; returns how many were accepted — see Extending the tracker database |
| _infra() | string[] | The 40 infrastructure hosts, as a copy |
| _isInfra(url) | boolean | Is this URL or hostname infrastructure? |
| _baseAllow | object | The built-in strict-mode allowlist (hosts plus path-scoped entries), as a copy |
ConsentKit._contrast, published by src/ck-ui.js, exposes the theme
arithmetic as pure functions so a theme editor can show exactly the numbers the
banner paints rather than reimplementing them. Nothing in it touches the DOM, so
it is safe to call in Node:
| Group | Functions |
|---|---|
| Colour maths | relativeLuminance, contrastRatio, ensureContrast, stepToContrast |
| Resolution | resolveButtonStyles, resolveRadius, resolveFont, pickPageFont, resolveDetails, buildThemeCss |
| Font probing | nextProbeDelay, shouldReprobe |
| Placeholders | placeholderText, placeholdersEnabled |
| Services panel | cookieRowsForService, looseCookies, servicePurpose, groupCountLabel, serviceSignature, signature |
| Wording | plural, pluralIndex, buildStrings, localeTable, resolveLang |
ConsentKit._resolvePageFont() reports the font family the banner resolved from
the page.
Events
All are CustomEvent on document, with the payload in detail.
| Event | detail | When |
|---|---|---|
| ck:init | { state, config } | From init(), after stored state is restored |
| ck:consent | { state } | The visitor's first choice |
| ck:change | { state } | Any change, including withdraw() |
| ck:ui:open-preferences | { state, config } | Command for the UI layer — show() dispatches it |
| ck:ui:close | { state } | Command for the UI layer — hide() dispatches it |
document.addEventListener('ck:change', (e) => {
const { state } = e.detail;
if (state.categories.analytics) startAnalytics();
});The core never touches the UI directly; it only dispatches these events, and the UI layer only calls the public API.
dataLayer events (GTM)
Separately from the DOM events above, integrations.gtmDataLayer (on by
default) pushes to window.dataLayer, which is what GTM triggers listen to:
| Push | When |
|---|---|
| ck_consent_update with ck_consent: { necessary, functional, analytics, marketing } and ck_method | Every decision, including withdraw() |
| ck_consent_functional / ck_consent_analytics / ck_consent_marketing | Once per granted category, on the decision and again on a return visit when stored consent is restored. Each fires at most once per page |
The per-category events exist so a GTM tag can trigger on exactly the category
it needs without reading the payload. See
integrations/gtm/README.md.
Blocking trackers
Manual markup
Mark a script as type="text/plain" with a data-ck category. The browser will
not execute it. Once the category is granted, ConsentKit recreates the element
with its real type and src.
<!-- external -->
<script type="text/plain" data-ck="marketing" data-src="https://connect.facebook.net/en_US/fbevents.js"></script>
<!-- inline -->
<script type="text/plain" data-ck="analytics">
console.log('runs only after analytics is granted');
</script>Iframes use data-src, which is applied once the category is allowed:
<iframe data-ck="marketing" data-src="https://www.youtube.com/embed/VIDEO_ID"
width="560" height="315" style="background:#e9edf5;border:0"></iframe>data-ck accepts any category name: functional, analytics, marketing.
On WordPress this markup is applied automatically, server-side, for every tracker in the built-in database — see "Server-side markup" below. Manual markup is still needed for trackers the database does not know (your own domain, an unlisted vendor) and for inline snippets.
Automatic blocking
Scripts injected at runtime are intercepted without any markup. ConsentKit
patches document.createElement, Element.prototype.setAttribute and the
HTMLScriptElement.prototype.src setter at parse time, matching the URL against
a built-in host list.
// Blocked until analytics is granted, then loaded automatically.
const s = document.createElement('script');
s.src = 'https://www.google-analytics.com/analytics.js';
document.head.appendChild(s);Blocked elements are marked data-ck-blocked and their URL is remembered, so
granting consent later loads them without a reload.
The database ships 167 hosts and 14 path rules, matched by suffix (a bare registrable domain also covers its subdomains) and by substring respectively:
| Table | Entries | By category |
|---|---|---|
| HOST_DB | 167 | 57 marketing, 52 functional, 36 analytics, 22 necessary |
| PATH_DB | 14 | 6 functional, 4 marketing, 2 analytics, 2 necessary |
| INFRA_DB | 56 | not a category — see Infrastructure |
Recognised hosts include Google Analytics, Facebook, Yandex Metrica, Hotjar,
TikTok and DoubleClick. The GTM container is deliberately not blocked (the
tags inside it obey Consent Mode); /gtag/js is blocked by path instead. The
same tables are exported to the WordPress plugin, so server and browser
classify a host identically.
Because the patches install at parse time, ck-core.js must load before any
tracker — put it first in <head> and do not add defer.
<iframe src> is covered by the same three patches, and a blocked frame keeps
its URL in data-src until its category is granted.
Extending the tracker database
The built-in host list is a snapshot, not an oracle. _extendHostDb() merges
extra host: category pairs into it at runtime:
ConsentKit._extendHostDb({
'analytics.vendor.example': 'analytics',
'pixel.partner.example': 'marketing'
});Matching is the same as for built-in entries — a bare domain also covers its
subdomains — and an override wins over the shipped classification for the same
host. Categories outside necessary | functional | analytics | marketing and
malformed hostnames are ignored; the call returns how many pairs were accepted.
It works both before and after init(). Calling it afterwards does not
re-examine anything already inserted (a script that has loaded cannot be
unloaded), but every later insertion is classified against the extended map.
In SaaS mode this is automatic: ck-saas.js applies config.hostdb from the
service before it calls init(), and again when a background revalidation
brings a changed table. At release time node tools/sync-hostdb.mjs bakes the
same public table into src/ck-core.js, so inline blocks, the npm package and
the WordPress plugin get it too.
Strict mode
By default ConsentKit blocks what it recognises. blocking.mode: 'strict'
inverts that for third parties: before consent, any <script src> or
<iframe src> pointing at a host that is not same-site is intercepted, whether
or not the tracker database has ever heard of it.
ConsentKit.init({
blocking: { mode: 'strict', allow: ['widgets.partner.example'] }
});Four things are never intercepted:
- Same-site URLs — the page's own host, its subdomains, and anything sharing its registrable domain. The check is deliberately conservative: when the answer is unclear it says same-site, because wrongly blocking a first-party asset breaks the site.
blocking.allow— your own list, matched by suffix.- The built-in allowlist, readable as
ConsentKit._baseAllow. Two parts: infrastructure (ConsentKit._infra(), see below) and things a page is unusable without (js.stripe.com,pay.google.com,checkout.creem.io, and reCAPTCHA — scoped towww.google.com/recaptchaandwww.gstatic.com/recaptcha, not to those hosts at large). - Known
necessary/functionalhosts already granted, which keep their real category rather than being swept up as marketing.
Anything else is filed under marketing — the strictest category — and
comes back only when the visitor accepts marketing.
Read this before switching it on. Strict mode will block third-party code
your site needs and that ConsentKit has no way to recognise as necessary: a
booking widget, a map, a review embed, a payment provider that is not on the
list. Turn it on, load the site with ?ck_debug=1, and read the "Blocked until
consent" list in the panel — entries the engine held back only because of strict
mode are labelled strict. Everything there that the page genuinely needs
belongs in blocking.allow.
Two limits are worth stating plainly:
- Dynamic insertions only, exactly as for known trackers. A tag written straight into the HTML starts its request before ConsentKit runs (see below). The WordPress plugin's server-side rewrite currently marks up known trackers only; extending it to strict mode is recorded as a follow-up in SPEC.md.
- Strict starts when the config does. The mode is read from
config, so in SaaS mode nothing is blocked strictly until the config has arrived. Blocking of known trackers still begins at parse time, as always.
Static tags: what the browser cannot catch
Runtime injection is covered by the patches above. A tracker tag written
directly into the HTML is not: the parser starts that request before the
first line of ck-core.js runs. The gap was measured (debt D9 in SPEC.md:
request at 14 ms, our script at 18 ms) and it is negative — no client-side technique
closes it. Such tags need either manual markup, or a server that rewrites them
before the page is sent.
Server-side markup (WordPress plugin)
The WordPress plugin does exactly that, and it is on by default since 0.3.5. While the page is generated, it rewrites tracker tags in the finished HTML:
<!-- what the theme wrote -->
<script src="https://mc.yandex.ru/metrika/tag.js"></script>
<!-- what the browser receives -->
<script type="text/plain" data-ck="analytics"
data-ck-src="https://mc.yandex.ru/metrika/tag.js"></script><iframe src> of a known host becomes data-ck + data-src with src
removed. The categories come from the same HOST_DB/PATH_DB as the browser
engine: tools/export-hostdb.mjs generates
plugins/wordpress/consentkit/includes/hostdb.php from src/ck-core.js, and
test/hostdb.test.mjs fails if the two drift.
What it skips: ConsentKit's own assets, tags carrying data-ck-ignore, tags
already marked up by hand, inline scripts (there is no URL to defer), the GTM
container, and anything inside comments, <pre> or <textarea>. On any error
the page is returned unchanged.
The <pre> / <textarea> skip keeps the source text byte-identical, which is
what a page documenting a tracker snippet needs. It does not keep such a tag
alive: the browser parses <pre><script src=…> as a real script element
whatever the server did, so the runtime engine may still intercept it. Caching plugins are compatible and get the
already-rewritten HTML, because the rewrite happens at the PHP level before the
page is cached.
Outside WordPress the same idea applies to any server-side template: emit the
type="text/plain" data-ck form directly, as in "Manual markup" above.
Google Consent Mode v2
With integrations.gcm (the default), the core pushes consent: default at
parse time — before any tag can load — with every signal denied and
wait_for_update: 500, then consent: update after each choice. Seven signals
are set, always as one block:
| Signal | Follows |
|---|---|
| analytics_storage | analytics |
| ad_storage, ad_user_data, ad_personalization | marketing |
| functionality_storage, personalization_storage | functional |
| security_storage | always granted |
Two page-level settings are pushed before the default (since 0.5.22;
as gtag('set', …) commands since 0.5.25 — 0.5.22–0.5.24 put them inside
the consent default object, where a tag manager does not recognise them):
| Flag | Value | What it does |
|---|---|---|
| url_passthrough | true | With ad_storage denied there is no cookie to carry a Google click id, so Google passes gclid in the URL instead. Without it a visitor who declined loses the click id on the next navigation, and the campaign that paid for the visit is credited to nobody. |
| ads_data_redaction | true | While ad_storage is denied, Google strips identifiers out of the ad requests themselves — a declined visitor is measured in aggregate rather than followed. |
Neither flag stores anything or weakens a refusal; they are what an honest refusal looks like on Google's side. If your own tag manager sets different values, it wins — these are defaults, not overrides.
When to set gcm: false. Only when your own code or container issues the
Consent Mode default and update commands itself. ConsentKit still pushes
its all-denied default at parse time — the config is not known yet, and a
Google tag that fires before anyone answered must find a denial waiting —
and your own default, issued after this script, overrides it. What
ConsentKit will NOT do with gcm: false is push an update: a site that turns
the gate off and pushes no update of its own keeps every visitor denied for
Google forever (gcs=G100 on every hit), including the ones who accepted.
Check with google_tag_data.ics.usedUpdate in the console after accepting:
true means the update reached Google.
integrations.gtmDataLayer (also on by default) is an independent gate: it
pushes a ck_consent_update event carrying ck_consent (the four categories)
and ck_method, so GTM triggers work even with gcm: false.
What "denied" actually means. Consent Mode is Google's own mechanism, not a block: a Google tag that runs under denied signals sets no cookies and no identifiers, but it still sends cookieless pings to Google, and those pings carry the page URL, the referrer and the user agent, from an IP address Google necessarily sees. That is enough for Google to see the visit, and in the EU an IP address is personal data. Consent Mode alone is therefore not the same as not being measured.
ConsentKit's blocking engine is the part that makes the difference: a tag it
holds back never runs at all, so it sends nothing — no ping, no URL, no IP. The
two work together, and Consent Mode is the fallback for the case the engine
cannot cover (a tag inside a GTM container, or a static <script src> the
parser requested before ConsentKit loaded — see
Static tags). If you need "nothing
reaches Google before consent", rely on the blocking engine and mark such tags
up; do not rely on Consent Mode by itself.
Storage
The decision is stored in a ck_consent cookie (base64 JSON, path=/,
SameSite=Lax, consentTtlDays) and mirrored to localStorage. It is
discarded — and the banner shown again — when policyVersion changes or the TTL
expires.
Geo rules
Since v0.5.17. By default every visitor sees the banner. geo narrows that to
a list of countries:
ConsentKit.init({
geo: { mode: 'list', countries: ['MD', 'RO', 'DE', 'FR'] }
});The country comes from the x-ck-country response header the hosted service
sets on the config request, and is read before the banner would render, so
nothing flashes. ConsentKit._geo holds { country, inScope } for the page.
For a visitor outside the list:
- the banner does not appear (the floating «cookie settings» button still does, so they can open the panel and decide for themselves at any time);
- every category is granted for this page load only — trackers run and Consent Mode receives granted signals;
- nothing is written down. No cookie, no
localStorage.getState()keeps reportingdecided: falsewithmethod: 'geo', so the same person visiting later from a country on the list gets a real banner rather than a consent they never gave; - the journal receives one record with
method: 'geo'per session, not per page.
If the country is unknown — a standalone page with no hosted config, a CORS
setup that does not expose the header — the visitor is treated as in scope and
the banner is shown. Showing a banner to someone who did not need one costs a
click; hiding it from someone who did is a compliance failure, so the default
falls that way deliberately. The same applies to mode: 'list' with an empty
countries array.
A stored decision always wins: geo never overrides a choice the visitor has already made.
Geo rules decide who is asked, not what the law requires. Picking a short list is a decision for you and your lawyer, not for this library.
One consent across domains
Since v0.5.17. Two independent switches, both off by default.
Subdomains
ConsentKit.init({ consent: { shareSubdomains: true } });The consent cookie is written on the registrable domain (.example.com) instead
of the exact host, so shop.example.com and blog.example.com read the same
decision and the visitor is asked once.
The registrable domain is found by probing: candidate parent domains are tried
shortest first and the first one the browser actually accepts is kept. That
matters for multi-label suffixes — on a.b.example.co.uk the browser silently
refuses .co.uk, so the first candidate that sticks is .example.co.uk, which
is the right answer. localhost and IP addresses get no domain= at all.
Because a sibling subdomain can now write the cookie, reading changes too: when
the cookie and localStorage disagree, the record with the newer ts wins.
With the switch off, the old cookie-first order is kept exactly.
Separate domains
ConsentKit.init({
consent: { linkedDomains: ['example.ro', 'example-shop.com'] }
});Different registrable domains cannot share a cookie, so the decision travels in
the link the visitor clicks. On a click on an <a href> pointing at a linked
host (or any of its subdomains), ConsentKit appends
#ck_consent=<base64url payload> to the href just before the navigation. The
payload carries a version, a timestamp and the categories plus any per-service
refusals — no id, no personal data.
On the receiving page the fragment is adopted as the visitor's decision — no
banner, a normal stored record, method: 'linked', one journal row — but only
when all of these hold:
- the timestamp is within 10 minutes (in either direction);
- the payload validates: version
1, all three categories present as booleans, services as a denial map; document.referreris one of the linked hosts, or empty (a strictReferrer-Policylegitimately sends none).
Anything else is ignored silently and the banner shows as usual. The fragment is
then removed with history.replaceState, leaving the rest of the fragment
intact.
At most 10 hosts are honoured, written without a scheme. Only http/https
links are touched; mailto:, tel: and the like are left exactly as authored.
Worth being clear about the threat model: a forged fragment can only ever grant consent on the page the visitor is already looking at — the same thing the «Accept all» button does. It cannot read anything, and it is validated against the schema above regardless.
Branding
By default the banner shows a small "Made by E-COM Consult" attribution line,
linking to ecomconsult.net. It is emitted as a branding object in the config,
and the prebuilt blocks in ready/ carry it.
Removing it is a supported, first-class option — no obligation, no nag:
node tools/build-inline.mjs --langs=en,ru --no-branding # block without itThe line is branding.poweredBy.text; with language: 'auto' supply
branding.poweredBy.texts instead — { ru: '…', ro: '…', en: '…' }, resolved
against the language the banner actually picked (texts[lang] → base code →
texts.en → text), so the attribution matches what the visitor is reading.
If you write the config by hand, simply omit the branding object; nothing
renders without it. Either way costs you ~200 bytes, not a licence: the client
is MIT and the line is yours to drop.
Note that only the attribution line ships, not the logo — brand/ecom-consult-logo.svg
is a white wordmark authored for dark backgrounds, so it would be invisible on
the banner's light surface. Supply your own branding.logo (and logoDark) if
you want a mark; see the branding notes in src/ck-ui.js.
In the **hosted
