A consent bar and the analytics loaders behind it: a first-party Plausible count by default; Google Analytics 4, Microsoft Clarity and PostHog each only under a purpose's yes, on the device and the account. One package holds the framework-free core, a React renderer, a plain DOM renderer, a static-site entry, server rules with a store on @framers/sql-storage-adapter, and the Playwright suite every consumer runs.
0.0.x versions are the build in progress; 0.1.0 is the first version with the contract below. In a 0.0.x version, an entry whose part has not landed exports VERSION alone.
| Entry | What it holds | Runs in |
|---|---|---|
. (the core) |
AnalyticsConfig, the purposes and the device's answer, the runtime, bootstrapSource, the device store, the reconciliation with the account, the account send (sendToAccount), the words' shape and hash rules, and the bar's controller |
Browser (ES2022), and Node for the string functions |
./react |
AnalyticsLoaders, Pageview, ConsentBar, PurposeRows, AnalyticsChoiceStatus, AnalyticsChoices, taking the product's words, purposes, paths and endpoint as props |
React 18.2 or later, or React 19 |
./dom |
The same bar, status line and choices rendered without a framework on the controller (mountConsentBar, mountChoiceStatus, mountAnalyticsChoices), the markup functions, and the page views for a page without React (watchPageviews, sendPageview) |
Browser |
./static |
For a generated site, at build time: bootstrapScript (the inline script and its CSP hash), consentBarMarkup, choiceStatusMarkup, optionsElement, and the path of the client file dist/static/consent-client.js (an IIFE bundle of ./dom and the core) |
Node, at build |
./server |
The account answer's pure rules (requestedPurposes, purposesOfRow, whoOf, deviceHashOf, judge, STALE_SENTENCE, consentAttributes) and a store on @framers/sql-storage-adapter (createConsentChoicesStore); it re-exports ./count |
Node (it imports node:crypto) |
./count |
The count's forwarding rules and forwarder (pageOf, siteOf, eventToForward, forwardCountEvent) |
Node and the Workers runtime (Fetch API only, no node: import) |
./testing |
consentSuite(options): the real-path Playwright cases every product runs |
@playwright/test, in CI |
./consent.css |
The bar's, rows', status line's and choices' stylesheet on --ac-* custom properties |
Imported once by the product |
import { bootstrapSource, scopeOf, type AnalyticsConfig } from '@framers/analytics-consent';
const config: AnalyticsConfig = {
product: 'Example',
plausible: { domain: 'example.com', script: '/js/count.js', endpoint: '/api/event', where: 'open' },
ga4: 'G-XXXXXXXXXX',
clarity: null,
posthog: { key: 'phc_xxx', host: 'https://us.i.posthog.com', cookieless: true },
tokenPaths: ['/signin'],
publicPaths: ['/', '/pricing'],
scopeBefore: null,
};
// The root layout's inline script. `words` is the product's AnalyticsWords.
const source = bootstrapSource(config, gpcHeader, signedIn, account, scopeOf(config, words));A layer set to null is off. The purposes that run follow the layers: count where plausible is set, measure where ga4 or posthog is, record where clarity is.
| Field | What it sets |
|---|---|
plausible.endpoint |
Where the tracker posts: the product's own event route, or the shared instance's event address on a site with no server route. |
plausible.where |
'everywhere', the default, loads the tracker on every page while count is on. 'open' loads it only on a public path, signed out, off the token paths, which a product that holds health data sets; window.__analytics.count says whether it loaded. |
posthog.cookieless |
true passes cookieless_mode: 'always' and request_batching: false to PostHog's init, for a product that holds health data. It needs the project's cookieless server hash mode (PostHog's config reference). |
tokenPaths |
Paths whose addresses carry a token: the recorders never load on them, and each layer counts them by path alone. |
publicPaths |
The only paths where Google Analytics, Clarity and PostHog may load, for a signed-out visitor, each under its purpose's yes. |
scopeBefore |
The scope an answer stored with no mark was given to, as a literal written once, or null for a new product. |
A privacy signal (Global Privacy Control, Do Not Track or a request with Sec-GPC: 1) turns every purpose off, the count included, in every configuration.
The bootstrap decides once per page load, from the address and the session as they stand at that load. A layer it loaded stays loaded across a client-side move, and the runtime keeps PostHog off a token path alone. So every link from a public page to a private page or a token address is a plain anchor, a full load after which the bootstrap decides again.
scopeOf(config, words) writes the running layers in one fixed form: each running purpose in the fixed order, its row's revision, and its vendors by name, sorted.
count:1:plausible;measure:1:ga4,posthog;record:1:clarity
The person's own answer stores that value under analytics_consent_scope beside the purposes. The bootstrap, and scopedPurposes for a caller holding the same values, read it before anything loads:
| What moved since the answer | What the answer means now |
|---|---|
A vendor added under a purpose, or its row's revision raised |
The yes to that purpose alone is void: nothing under it loads until the next answer. |
| A vendor removed | The yes stands. |
| A purpose the answer's scope does not list, its layer added after the answer | It is unanswered, and nothing under it loads until the next answer. |
| The answer refused a listed purpose | The refusal stands, whatever the scope. |
| The mark is absent or does not parse | The answer is read as given to scopeBefore. Under null its yes is void and its refusals stand. |
The count runs without a yes, so a voided or unlisted count stays on as unanswered, unless the answer holds it off. voidedByScope names what an answer leaves unanswered, the count included.
bootstrapSource throws on a scope that parses but lists other purposes, or other vendors under a purpose, than the configuration runs, since a layer the scope leaves out would load under any stored yes to its purpose. Pass scopeOf(config, words) for the same configuration, never a literal.
A row's revision starts at 1 and stays at or below Number.MAX_SAFE_INTEGER; scopeOf throws past it. Raise it by one in the change that alters what a person agrees to under that row: a vendor's role, what it sets, what reaches it, where or how long it runs. A spelling or style edit leaves it. The revision sits outside the words' three hashed texts, so raising it changes no hash.
The core keeps the person's answer on the device and reconciles it with the signed-in account's at each load. The bar does both for a product, and a product's own controls call the same functions.
readDevice()andwriteDevice(store, answer, at, who, words, carried, scope)hold the answer and its marks inlocalStorage. The person's own answer passesscopeOf(config, words)asscope. A write that only carries the account's refusals in passes none, and the mark stays, so a yes never gains a scope it was not given.reconcile(device, pageAccount(), signal, words)answers what a load does: nothing, ask, write the device, or write the account. A refusal carries everywhere; a yes is given on each device and never carried from the account. A yes the scope no longer covers, and a running purpose the answer's scope does not list, are asked for again, and such a yes is never sent to an account that holds no answer. A refusal of a listed purpose stands.switchesFor(device, pageAccount())is where the switches open: the person's own answer, else the defaults less the device's refusals; an unanswered purpose at its default (the count on, the others off), or off where the account, or an answer carried from it, holds it off.- Both read the page's scope and
scopeBeforefromwindow.__analytics(currentScope(),currentScopeBefore()) unless they are passed. sendToAccount(answer, signal, account, textHash, { endpoint, deadlineMs, fetch })posts{ purposes, textHash, signal, version }as JSON to/api/v1/consentunlessendpointnames the product's route, with the site's cookies and fetch'skeepalive, and waits 30 seconds unlessdeadlineMsnames another wait. Chromium does not end a keepalive request at the deadline's abort, so the route can still receive an answer the page has given up on. It answers{ ok: true, version, chosenAt }, or null for a refusal, a request that never arrives, the deadline or a body of any other shape. On null the device keeps its answer, and the next signed-in load reconciles it.writeDevicesendsCHOICE_EVENT(analytics-consent:choice) to its own tab;subscribeChoicefollows it and the other tabs' storage events.
The contract, whose change is breaking:
- the purposes (
count,measure,record) and their order; - the storage keys (
analytics_consent_purposes,analytics_consent,analytics_consent_written,plausible_ignore,analytics_consent_at,analytics_consent_who,analytics_consent_words,analytics_consent_carried,analytics_consent_scope); AnalyticsConfig's shape,scopeOf's format, the scope rule and the words'revision;- the account route's body and answer;
- the
<html>attributes (data-consent-who,data-consent-purposes,data-consent-choice,data-consent-at,data-consent-version,data-consent-unreadable); - the bar's region attribute
data-consent-barand the--consent-barand--consent-roommeasures; window.__analytics;- the words' shape and hash rules;
- the
ac-class names and--ac-*properties.
Pin an exact version ("@framers/analytics-consent": "0.1.3", never a caret or a range) and take a newer one at your own release. A breaking change raises the minor while the package is below 1.0.0.
A product maps these custom properties once in its own stylesheet. Each fallback is listed here from the version whose stylesheet sets it.
| Property | What it sets |
|---|---|
--ac-panel, --ac-panel-border, --ac-panel-shadow |
The bar's surface |
--ac-ink, --ac-ink-2 |
Text |
--ac-line, --ac-edge, --ac-well |
Rules, edges and the switch's well |
--ac-accent, --ac-on-accent |
The checked switch's border and fill, and what sits on it |
--ac-key-bg |
The keys' background |
--ac-radius-card, --ac-radius-pill |
Corners |
--ac-font-ui |
Type |
--ac-space-4, --ac-space-8, --ac-space-12, --ac-space-16, --ac-space-20 |
Spacing |
--ac-room-above, --ac-room-below |
The room the bar leaves above and below itself |
On SQLite, open one handle per database file and send every write of the store through it. A reader holding a read transaction (a backup, an inspection) or a second process can still make a commit fail busy, which the store answers as a failed send: nothing is stored, the device keeps its answer, and the next signed-in load reconciles it.
Install this repository's dev dependencies with npx npm@11.20.0 install: npm 10.9, which Node 22 ships, crashes resolving their peer sets. Installing the published package is not affected; CI installs the packed tarball with Node's own npm.
CI runs npx vitest run --coverage and fails below the thresholds in vitest.config.ts, which hold the measured figure less two points and only move up. Each green run on master writes the figure to the badges branch, which CI alone owns and the coverage badge reads.
Every push to master that changes what the package ships publishes its next patch to npm with provenance. RELEASING.md holds the rules.