npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

ConsentKit

status: prototype v0.5 license: MIT dependencies: 0 no build step

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 GA4 gtag/js tag) 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/consentkit

Or 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 chain branding.poweredBy.texts and texts.links[].label use, 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. null or 0 renders the localised word for a session cookie (сессия / sesiune / session); a positive number renders localised plural forms (2 дн. / 2 zile / 2 days). When expiryDays is absent the old expiry string is shown; when both are present expiryDays wins. 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, from navigator.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):

  1. <html lang> — the explicit declaration, and it always wins.
  2. 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, like pt-br) counts, so /ruby/ is not Russian and /engineering/ is not English.
  3. <meta property="og:locale"> — ro_RO → ro.
  4. navigator.language.
  5. 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 fg you 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 border you set on an outline button is painted as set; a border derived from theme.accent is 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:

  1. Paragraphs. A blank line (two newlines) starts a new paragraph. Each paragraph becomes one <p>.
  2. Line breaks. A single newline inside a paragraph becomes a <br>.
  3. Three passes, in this exact order. The order is part of the contract, not an implementation detail:
    1. [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 a mailto: label, and leaves a ** inside a link label literal.
    2. **bold**, over the text between those links.
    3. Bare addresses, inside each bold and each plain span: a bare https:// or http:// 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.
  4. What the passes produce. Every link — [label](https://…), [label](mailto:…), a bare URL, a bare e-mail — is rendered with target="_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.
  5. 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.
  6. No HTML. <b> is four characters of text. Nothing in this path goes through innerHTML; the block is built with createElement and createTextNode only. 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:

  1. 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.
  2. blocking.allow — your own list, matched by suffix.
  3. 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 to www.google.com/recaptcha and www.gstatic.com/recaptcha, not to those hosts at large).
  4. Known necessary / functional hosts 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 reporting decided: false with method: '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.referrer is one of the linked hosts, or empty (a strict Referrer-Policy legitimately 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 it

The 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