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

@consenti/ui

v0.4.0

Published

GDPR-style cookie consent widget — zero required runtime dependencies

Downloads

593

Readme

@consenti/ui

GDPR-style cookie consent widget for any web framework — zero required runtime dependencies.

npm version License Browser


Why Consenti?

  • Zero runtime dependencies — browser built-ins only (fetch, crypto.subtle, BroadcastChannel, CustomEvent, document.cookie, localStorage)
  • Fully standalone or API-connected — works offline with pre-built profiles, or connects to @consenti/api for dashboard-managed profiles
  • Multi-regulation — Maintained: GDPR, CCPA, CPRA, LGPD. Partial: TCF v2.3 (real binary encoding requires the @consenti/api backend) — only relevant if you monetize through programmatic/RTB ad exchanges. In development: DPDPA. More via 8 built-in compliance groups — see ECOSYSTEM.md for per-regulation status

Registration is only needed for programmatic ad monetization. For first-party analytics/marketing consent, Consenti is fully compliant with zero external registration. Consenti implements the IAB TCF/GPP technical specs but is not itself a registered CMP; if you do need TCF/GPP, you must register your own cmpId with IAB Europe/MSPA independently before going live — see the TCF & GPP Registration Guide.

  • Automatic geo-resolution — detects visitor jurisdiction client-side (timezone + language) or server-side (/resolve-profile)
  • GPC aware — Global Privacy Control signal detection with 'honor' / 'strict' / 'ignore' modes per profile
  • GTM / Google Consent Mode v2 — built-in dataLayer integration
  • SSR-safe — all browser API access is guarded; new ConsentiSetup() during SSR is a silent no-op
  • Fully typed — ships TypeScript definitions; no @types needed
  • Companion CLI: @consenti/scanner crawls a site under none/reject-all/accept-all consent states and reports undeclared third-party trackers

Browser Support

ES2020+ · Chrome 80+ · Firefox 74+ · Safari 13.1+


Installation

npm (recommended)

npm install @consenti/ui

Then import the class:

import { ConsentiSetup } from '@consenti/ui'

CDN / UMD (no build step)

<script src="https://cdn.jsdelivr.net/npm/@consenti/ui/dist/index.umd.js"></script>
<script>
  const { ConsentiSetup } = ConsentiUI
  new ConsentiSetup({ compliance: { type: 'opt-in' } })
</script>

ESM in the browser (no bundler)

<script type="module">
  import { ConsentiSetup } from 'https://esm.sh/@consenti/ui'
  new ConsentiSetup({ compliance: { type: 'opt-in' } })
</script>

CSS options

Choose any approach to load stylesheet, by default it will be auto injected

| Approach | How | |-----------------------------------|-----------------------------------------------------------------------| | Auto Injection (recommended) | by default consenti will inject css | | Manual injection (avoids FOUC) | import '@consenti/ui/dist/index.css' in your bundler entry or code and set disableCssTemplate: true; | | Skip all CSS (bring your own) | Set core.disableCssTemplate: true — no styles injected | | CSS custom properties only | Import CSS, then override --consenti-* variables in your stylesheet | | core.theme config in ConsentiSetup| Provide css variables for your stylesheet in the setup as js object |


Quick Start

// Simplest possible — auto-detects jurisdiction from browser timezone + language
new ConsentiSetup({})

// Fixed GDPR (EU opt-in) profile, no backend required
new ConsentiSetup({ compliance: { type: 'opt-in' } })

// API-backed — server geo-resolves the right profile per visitor
new ConsentiSetup({
  api: { enabled: true, baseUrl: 'https://your-backend.com' },
})

A consent banner appears on first visit. Everything below is optional.


Package Exports

@consenti/ui             ← main entry (ConsentiSetup, ConsentiProfile, etc.)
@consenti/ui/react       ← React hook (useConsent)
@consenti/ui/vue         ← Vue composable (useConsent)
@consenti/ui/angular     ← Angular service (ConsentiService)
@consenti/ui/testing     ← Test utilities

Full Configuration

import { ConsentiSetup } from '@consenti/ui'

const widget = new ConsentiSetup({
  // ── Core (optional) ──────────────────────────────────────────────────────────
  core: {
    tenantId: 'acme',              // identifies your tenant's profiles; default: 'default'
    locale: 'en',                  // BCP 47; 'auto' = navigator.language; default: 'auto'
    dir: 'auto',                   // 'ltr' | 'rtl' | 'auto'; 'auto' derives from locale via
                                   // Intl.Locale(...).getTextInfo().direction (falls back to a
                                   // hand-maintained ar/he/fa/ur/ps/sd/ug/yi → rtl list on
                                   // engines without getTextInfo() support); default: 'auto'
    storage: 'cookie',             // 'cookie' | 'localStorage'; default: 'cookie'
    cookieName: 'consenti_data',   // cookie/localStorage key name; default: 'consenti_data'
                                   // not switched automatically by detected region — set
                                   // explicitly if you want a different name (e.g. 'euconsent-v2',
                                   // only meaningful if you're using the spec-correct binary TCF
                                   // encoder — @consenti/api + @iabtechlabtcf/core — since that
                                   // name implies IAB's binary format, not Consenti's own encoding)
    cookieDomains: '.example.com', // comma-separated; first entry used as Domain attribute
    cookieSigningKey: 'min-32-char-secret', // HMAC-SHA256 signing; implicit from presence
    allowReceipt: true,            // allow consent receipt download; default: false
    disableCssTemplate: false,     // skip all style injection; default: false
    userId: 'server-assigned-uuid', // authenticated users — enables cross-device sync
                                   // prefer widget.getUserId()/setUserId() (or the
                                   // consenti:listener:identify event) to change it after init

    // Pre-built profile fallback
    usePrebuiltProfiles: 'all',    // 'all' (default) | ['opt-in', 'opt-out', ...] (non-empty)
                                   // controls which of the 8 built-in profiles are available
                                   // as fallbacks when the API is unavailable
    cacheResolvedProfiles: true,   // cache /resolve-profile response in sessionStorage; default: true
    console: ['error'],            // log levels emitted: 'info' | 'log' | 'warning' | 'error'
                                   // default: ['error'] only — no noise in production

    // Field names mirror their --consenti-* CSS variable literally (--consenti-color-primary
    // → colorPrimary). Every field is optional and maps 1:1 to one CSS custom property — see
    // apps/ui/src/styles/_variables.scss for the full set of ~33 variables. Common ones:
    theme: {
      colorBg: '#ffffff',
      colorText: '#1a1a1a',
      colorTextMuted: '#949dab',
      colorPrimary: '#1565c0',
      colorPrimaryText: '#ffffff',
      colorSecondary: '#f0f4f8',
      colorSecondaryText: '#1a3460',
      colorBorder: '#e2e8f0',
      colorAccent: '#d32f2f',
      colorAccentText: '#ffffff',
      fontFamily: 'system-ui, sans-serif',
      fontSizeBase: '14px',
      fontSizeHeading: '18px',
      fontSizeMultiplier: '1',
      borderRadius: '8px',
      borderRadiusBtn: '4px',
      toggleBgOn: '#1565c0',
      toggleBgOff: '#cccccc',
      // ...plus colorSecondaryBorder, colorOverlay, fontFamilyMono, fontWeightHeading,
      // lineHeight, spacingXs/Sm/Md/Lg, shadow, toggleBgPartial, toggleKnob,
      // toggleWidth/Height, zBanner/Overlay/Modal
    },
  },

  // ── Compliance routing (optional) ─────────────────────────────────────────
  compliance: {
    // 'auto'              → geo-resolve per visitor (default)
    //                       api.enabled=true  → server resolves via /resolve-profile
    //                       api.enabled=false → resolves client-side from timezone + language
    // ComplianceGroupId   → fixed group for all visitors (skip geo)
    // localProfileType    → use a locally registered ConsentiProfile
    type: 'auto',

    // Client-side geo resolver (used only when api.enabled = false)
    // 'default' → browser timezone + navigator.language heuristic (built-in, zero deps)
    // WidgetCountryResolverFn → custom async function: () => Promise<{ country, region, confidence }>
    geoDataProvider: 'default',

    // Only meaningful when api.enabled = false (ignored, with a warning, otherwise — the server
    // resolves the group in that mode). Only overrides country→group mapping; country/region
    // detection above always uses the embedded geo data.
    // 'default' → embedded map (default) | a URL string (fetched; the browser's own HTTP cache
    // honors whatever Cache-Control/ETag the response sends) | an inline ComplianceMapData object
    complianceMap: 'default',

    // TCF (IAB Transparency & Consent Framework) client stub (optional) — see "TCF" section below
    tcf: {
      enabled: false,
      cmpId: 0,       // must match the cmpId configured on the backend (TcfConfig)
      cmpVersion: 1,
    },

    // GPP (IAB Global Privacy Platform, US National section) client stub (optional) — see "GPP" section below
    gpp: {
      enabled: false,
      cmpId: 0,                    // must match the cmpId configured on the backend (GppConfig)
      cmpVersion: 1,
      mspaCoveredTransaction: false, // no honest default — required
      mspaOptOutOptionMode: 0,     // 0 = not applicable | 1 = yes | 2 = no
      mspaServiceProviderMode: 0,
    },
  },

  // ── Mount point (optional) ────────────────────────────────────────────────
  rootEl: '#my-consent-wrapper',  // CSS selector or HTMLElement; default: appends to body

  // ── Dark mode (optional) ──────────────────────────────────────────────────
  darkMode: false,                // or: window.matchMedia('(prefers-color-scheme: dark)').matches

  // ── Backend API (optional) ────────────────────────────────────────────────
  api: {
    enabled: true,
    baseUrl: 'https://your-site.com',  // default: window.location.origin
    authToken: '',                      // sent as Authorization: Bearer <token>
    tenantId: 'acme',                   // overrides core.tenantId for API calls
    complianceGroup: 'opt-in',          // pin a specific group on the API side (Scenario 2B)
    trustDomain: false,                 // true = bypass allowedOrigins check (use in dev only)
  },

  // ── GTM / Google Consent Mode v2 (optional) ───────────────────────────────
  utils: {
    gtm: {
      containerId: 'GTM-XXXXXX',
      dataLayer: 'dataLayer',
      events: [],                 // [] = all events; list names to filter
      urlPassthrough: true,       // cookieless conversion modelling
      adsDataRedaction: false,    // redact ad pings when consent denied
    },
  },

  // ── Frontend plugins (optional) ───────────────────────────────────────────
  plugins: [],

  // ── Runtime profile overrides (optional) ──────────────────────────────────
  profileOverride: {
    mainBanner: { position: 'top' },
  },
})

profileOverride — deep-merge and delete semantics

profileOverride (and widget.setProfile() at runtime) is deep-merged onto the resolved profile — server-fetched, pre-built, or locally registered — before anything renders. Merge rules:

  • Omitting a key (or setting it to undefined) leaves the resolved profile's value untouched.
  • An object value merges recursively, key by key — you only need to specify what differs.
  • An array value replaces/merges by index against the base array.
  • Setting a key to null deletes it from the merged result (JSON Merge Patch / RFC 7396 semantics). This is the way to remove a single entry from a keyed map — a cookie category, a parameter — without having to know or repeat the rest of that map's contents:
profileOverride: {
  preferenceModal: {
    categories: { marketing: null },   // removes the 'marketing' category entirely
  },
},

{ marketing: undefined } or { marketing: {} } do not delete the entry — the former is a no-op (same as omitting the key), the latter merges an empty object onto the existing category, changing nothing. Only an explicit null removes it.


Compliance Groups

Consenti ships 8 pre-built English profiles, one per compliance group. The active group is resolved automatically per visitor (API or client-side) or fixed globally via compliance.type.

| Group | Region / Law | Model | GPC Default | |----------------------------|-------------------------------|------------|-------------| | 'opt-in' | EU / EEA — GDPR | Opt-in | 'honor' | | 'opt-out' | California — CCPA | Opt-out | 'honor' | | 'opt-out-strict' | California — CPRA | Strict opt-out | 'strict' | | 'opt-in-dpdpa' | India — DPDPA | Opt-in | 'honor' | | 'opt-in-china' | China — PIPL | Opt-in | 'ignore' | | 'opt-in-brazil' | Brazil — LGPD | Opt-in | 'honor' | | 'general-privacy-consent'| Global / general | Opt-in | 'honor' | | 'notice-only' | Informational | Notice | 'ignore' |

Fixed group (no geo-routing)

// All visitors see the GDPR opt-in banner
new ConsentiSetup({ compliance: { type: 'opt-in' } })

// All visitors see the CCPA opt-out notice
new ConsentiSetup({ compliance: { type: 'opt-out' } })

Auto geo-routing (default)

// Client-side: timezone + navigator.language → compliance group
new ConsentiSetup({ compliance: { type: 'auto' } })

// Server-side: /resolve-profile → right profile per visitor country
new ConsentiSetup({
  api: { enabled: true, baseUrl: 'https://consent.example.com' },
  compliance: { type: 'auto' },
})

Profile Resolution Scenarios

| Scenario | When | How | |---|---|---| | 1A | api.enabled = false, compliance.type = 'auto' | Timezone + language → group → pre-built profile | | 1B | api.enabled = false, compliance.type = ComplianceGroupId | Direct pre-built profile load | | 2A | api.enabled = true, compliance.type = 'auto' | GET /resolve-profile?tz&lang&locale&tenantId → filePath → static JSON | | 2B | api.enabled = true, compliance.type = ComplianceGroupId | GET /profiles/:tenantId/:group/:locale | | Local | compliance.type = localKey + registered ConsentiProfile | Locally registered profile | | Fallback | Any fetch failure | Pre-built profile → DEFAULT_PROFILE |

The /resolve-profile response URL is cached in sessionStorage for 1 hour per tab (core.cacheResolvedProfiles). The profile JSON itself is HTTP-cached via Cache-Control: public, max-age=3600 on the server.

Domain allowlist

If a profile's allowedOrigins list is configured on the server, the widget checks window.location.origin against it before using the profile. A mismatch falls back to the pre-built profile for that group.

Set api.trustDomain: true to bypass this check (useful for localhost / dev environments where allowedOrigins contains only production domains).


GPC — Global Privacy Control

GPC mode is set per-profile on the server (gpcMode: 'honor' | 'strict' | 'ignore'). Widget config profileOverrides overrides the profile value when explicitly set.

gpcMode only applies once the widget itself has loaded and initialized. If a tag-loading script sits earlier in <head> (GTM/gtag.js, or another script), it can still fire before that. Use buildSyncGpcSnippet() to close that gap — it returns a tiny, dependency-free <script> string to place first in <head>, ahead of everything else, that checks navigator.globalPrivacyControl synchronously and pre-freezes Google Consent Mode v2 to denied:

import { buildSyncGpcSnippet } from '@consenti/ui'

buildSyncGpcSnippet()                                  // default dataLayer name
buildSyncGpcSnippet({ dataLayerName: 'myDataLayer' })  // custom dataLayer name

Safe to run before Consenti's own gtm config pushes its defaults on init — same values, same stub-queue, so the second push is a no-op rather than a conflict. Full walkthrough with the <head> ordering: Advanced Configuration → GPC.


Age Gate

Age gate is a per-profile, per-locale setting — not a widget-config option. GDPR Article 8's consent age varies 13–16 by EU member state and DPDPA has its own child-data age rules, so one global age doesn't fit a multi-region deployment. Enable it on a profile in the dashboard's Profile Editor Step 1 (minimum age, optional parental-consent requirement), and author the modal's heading/body/button text per locale in the Main Banner content step — the widget reads it off the resolved profile (profile.ageGate/profile.ageGateModal) automatically, no widget config needed. For a standalone/local profile (registerProfile()), set ageGate/ageGateModal directly on the EmbeddedProfile/EmbeddedTranslations you register.

When enabled, it blocks every other consent UI (banner, GPC, CCPA opt-out — all of it waits behind the age gate on first visit):

  • Confirmed (visitor is old enough) → the normal banner/GPC/CCPA flow proceeds exactly as if the age gate didn't exist, and every consent submission from then on carries ageVerified: true.
  • Declined, requireParentalConsent: false → a deny-all consent is submitted immediately (mandatory/strictly-necessary cookies still granted), no banner shown, ageVerified: false.
  • Declined, requireParentalConsent: true → same deny-all submission, plus a parentalConsentToken is requested from the backend (POST /consent/:visitorId/parental-consent-request, signed with the backend's compliance.dataSigningHash when configured — falls back to a client-generated, unsigned token if api.enabled is false) and a consenti:parentalConsentRequired event fires carrying it. There's no email-sending pipeline built into this package — the event (or your backend's eventBus.on('consent.parentalConsentRequired', ...) listener, see the @consenti/api README's "Parental consent" section) is the hook for wiring your own out-of-band verification process.

When the parent later completes that process (often on a fresh page with no live widget instance — their own device, not the child's), resolve the token with the standalone resolveParentalConsent export instead of a ConsentiSetup method:

import { resolveParentalConsent } from '@consenti/ui'

const { visitorId, profileId } = await resolveParentalConsent(token, { baseUrl: 'https://consent.example.com' })
// dispatches consenti:parentalConsentResolved on window — a page with a live widget instance
// can listen for it and react (e.g. re-show the banner for the real consent choice); what
// "resolved" should actually do to consent state is host-defined, same as the request side.

This is plumbing, not verifiable parental consent by itself — see the @consenti/api README for the full caveats (token replay isn't prevented, staying stateless is a deliberate tradeoff).

The prompt only asks once per visitor per profile — it doesn't re-appear once any consent record exists (same "already decided" check the banner itself uses).


TCF — IAB Transparency & Consent Framework

Set compliance.tcf.enabled: true to install window.__tcfapi, the standard IAB entry point third-party ad-tech scripts call to read consent (ping, getTCData, addEventListener, removeEventListener):

new ConsentiSetup({
  compliance: {
    tcf: { enabled: true, cmpId: 280, cmpVersion: 1 }, // cmpId must match the backend's TcfConfig
  },
})

The stub follows the standard queue-command convention, so it's safe regardless of load order relative to third-party scripts that call __tcfapi before Consenti has initialized. Only one CMP may own window.__tcfapi per page — if it's already set (a real CMP, or another ConsentiSetup instance on a multi-profile page), this stub does not overwrite it.

This is a simplified implementation: tcString is a base64url-encoded JSON payload (the same default format the backend's tcfString uses), not the full IAB binary bitfield encoding, and vendor-list metadata (gvlVersion, publisherCC) is operator-supplied via TcfWidgetConfig rather than fetched client-side (the GVL is multi-megabyte; IAB policy expects CMPs to cache their own copy server-side rather than have every visitor's browser re-fetch it). gdprApplies is derived automatically from the resolved profile's compliance group.

For spec-correct binary TC-string encoding, run with the @consenti/api backend, set compliance.tcf.publisherCC, and install the optional @iabtechlabtcf/core peer dependency (the actively-maintained IAB Tech Lab package — not iabtcf-core, which doesn't exist on npm). See the TCF & GPP Registration Guide for details.


GPP — IAB Global Privacy Platform (US National)

Set compliance.gpp.enabled: true to install window.__gpp, the standard IAB entry point scripts call for US state-privacy-law signals (ping, addEventListener, removeEventListener, getSection, hasSection) — usnat (US National) section only:

new ConsentiSetup({
  compliance: {
    gpp: {
      enabled: true,
      cmpId: 280,                   // must match the backend's GppConfig
      cmpVersion: 1,
      mspaCoveredTransaction: true, // whether this deployment falls under MSPA signatory obligations
      mspaOptOutOptionMode: 1,      // 0 = not applicable | 1 = yes | 2 = no
      mspaServiceProviderMode: 0,
    },
  },
})

Sale/sharing opt-out flags are derived automatically from the resolved profile's cookies tagged cpraCategory: 'sale' / 'sharing' and the visitor's actual consent — no separate config needed. Like TCF, only one CMP may own window.__gpp per page; this stub does not overwrite an existing one, and follows the standard stub-queue convention so load order relative to third-party scripts doesn't matter.

Unlike TCF, GPP's US National section needs no Global Vendor List, so both this stub's gppString and the backend's gppString use the same real, spec-correct encoder when the optional @iabgpp/cmpapi peer dependency is installed on @consenti/api — there's no "simplified fallback" format the way TCF has. Without it, gppString falls back to a base64url-encoded JSON payload.


RTL / Text Direction

core.dir controls the banner/modal's reading direction:

new ConsentiSetup({ core: { locale: 'ar', dir: 'auto' } }) // → rtl, derived from locale
new ConsentiSetup({ core: { dir: 'rtl' } })                 // explicit, independent of locale

'auto' (the default) maps a known RTL-language prefix (ar, he, fa, ur, ps, sd, ug, yi) to rtl; anything else is ltr. The dir attribute is set on the widget's root element, so browser-native mirroring (flex layout, text-align: start/end) and the widget's own [dir="rtl"] CSS overrides (toggle switches, locale switcher, close button) apply automatically — no separate RTL stylesheet needed. Explicit banner/modal position variants (left-bottom, right-bottom, modal left/right slide-in) are not mirrored — those are screen positions you chose deliberately, independent of text direction.


Profiles

Pre-built profiles (no backend)

The widget includes 8 pre-built English profiles as dynamic-import chunks. Only the matched chunk downloads at runtime.

// GDPR banner — no server needed
new ConsentiSetup({ compliance: { type: 'opt-in' } })

// CCPA notice — no server needed
new ConsentiSetup({ compliance: { type: 'opt-out' } })

// Restrict which pre-built profiles are available as fallbacks
new ConsentiSetup({
  core: { usePrebuiltProfiles: ['opt-in', 'opt-out'] },
})

Local profile (no backend)

Define a full profile in JavaScript — no server required.

import { ConsentiProfile, ConsentiSetup } from '@consenti/ui'

const profile = new ConsentiProfile({
  defaultLocale: 'en',
  complianceGroup: 'opt-in',
  cookies: {
    necessary: { purpose: 'necessary', listenGpc: false },
    analytics: { purpose: 'analytics', listenGpc: true },
    marketing: { purpose: 'marketing', listenGpc: true, cpraCategory: 'sale' },
    // cpraCategory: 'sale' | 'sharing' | 'sensitive' — used by CPRA group
  },
  translations: {
    en: {
      mainBanner: {
        position: 'bottom',      // 'top' | 'bottom' | 'middle' | 'left-bottom' | 'right-bottom'
        overlayOpacity: 0,       // 0–100
        showClose: false,
        showLocaleSwitcher: false, // true = show locale switcher (requires multiple locales)
        heading: 'We value your privacy',
        headingTag: 'h2',        // HTML tag for the heading; default 'h2'
        htmlText: 'We use cookies to improve your experience.',
        buttons: {
          // key = machine id, rendered as the button's DOM id: `consenti-btn-{id}`, for targeting a specific button
          // cookies: '*' = grant all | '!' = deny all | ['id1','id2'] = grant specific
          'accept-all': { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
          'reject-optional': { text: 'Reject Optional', style: 'primary', action: 'custom', cookies: '!' }, // same weight as accept-all — CNIL requires reject not be visually subordinate
          customize: { text: 'Customize', style: 'secondary', action: 'manage' },
          'privacy-policy': { text: 'Privacy Policy', style: 'text', action: 'link', url: '/privacy' },
        },
      },
      gpcBanner: {               // shown instead of mainBanner when GPC detected
        position: 'bottom',
        heading: 'Privacy signal detected',
        headingTag: 'h2',
        showLocaleSwitcher: false,
        htmlText: "Your browser's GPC signal was detected. Ad cookies have been pre-denied.",
        buttons: {
          understood: { text: 'Understood', style: 'primary', action: 'custom', cookies: '!' },
          customize: { text: 'Customize', style: 'secondary', action: 'manage' },
        },
      },
      preferenceModal: {
        heading: 'Cookie Preferences',
        headingTag: 'h2',
        subheading: 'Choose which cookies you allow.',
        htmlText: 'We use different types of cookies. You can enable or disable each category below.',
        position: 'center',       // 'left' | 'right' | 'center'
        showClose: true,
        showLocaleSwitcher: false,
        persistent: false,        // true = cannot dismiss by clicking outside
        overlayOpacity: 50,
        mobileFullScreenBreakpoint: 576,
        buttons: {
          'accept-all': { text: 'Accept All', style: 'primary', action: 'custom', cookies: '*' },
          'save-preferences': { text: 'Save Preferences', style: 'primary', action: 'submit' },
          'reject-optional': { text: 'Reject Optional', style: 'text', action: 'custom', cookies: '!' },
        },
        categories: {
          necessary: {
            heading: 'Strictly Necessary',
            headingTag: 'h3',
            htmlText: 'Required for the site to function. <strong>Cannot be disabled.</strong>',
            legalBasis: 'mandatory',
            cookies: ['necessary'],
          },
          analytics: {
            heading: 'Analytics',
            htmlText: 'Helps us understand how visitors use the site.',
            legalBasis: 'consent',     // 'mandatory' | 'consent' | 'legitimate_interest'
            cookies: ['analytics'],
          },
          marketing: {
            heading: 'Marketing',
            htmlText: 'Used to personalise ads and measure campaign performance.',
            legalBasis: 'consent',
            cookies: ['marketing'],
          },
        },
      },
    },
    fr: {
      // same shape — add translations for each locale
      mainBanner: { /* ... */ },
      preferenceModal: { /* ... */ },
    },
  },
})

new ConsentiSetup({
  compliance: { type: profile.getComplianceGroup() },
})

API profile (with backend)

new ConsentiSetup({
  api: {
    enabled: true,
    baseUrl: 'https://consent.example.com',
    tenantId: 'acme',
  },
  // compliance.type: 'auto' resolves the right profile per visitor country
})

The widget calls GET /resolve-profile?tz&lang&locale&tenantId, receives the profile file path, then fetches that static JSON directly. Falls back to pre-built profile if the API is unavailable.


Cookie Format

Consent is persisted as a single cookie named consenti_data with a compact JSON value:

{"s":1,"v":2,"i":"5huf-…","u":"","t":1751692400,"g":0,"p":0,"c":{"analytics_storage":"g","ad_storage":"d"}}

| Field | Type | Meaning | |---|---|---| | s | number | Profile ID | | v | number | Profile version | | i | string | Per-submission UUID (consent receipt ID) | | u | string | Logged-in user ID ("" for anonymous) | | t | number | Unix timestamp of consent | | g | 0 | 1 | GPC signal detected | | p | number | Consent source: 0 = user click, 1 = widget method | | c | object | Consent map: cookie ID → "g" (granted), "o" (objected), "d" (denied) |

When the widget is upgraded from an older version, any existing consenti_{profileId} cookie is automatically read, migrated to the new format, and deleted — no consent loss occurs.

The consent cookie lifetime equals the shortest cookie.expiry value across all profile cookies (in days, converted to max-age seconds). When that cookie expires the browser deletes it, the widget sees no consent, and re-shows the banner.


Display Features

Footer Metadata

When showFooterMetadata: true is set in the resolved profile, a metadata strip is injected inside the banner and modal after the main content. It shows the visitor's Consent ID (truncated UUID), Consent Date, Profile Version, and a "Privacy Settings" button that opens the preference modal.

Configured in the dashboard (ProfileEditor → Step 1) or via profileOverride:

new ConsentiSetup({
  compliance: { type: 'opt-in' },
  profileOverride: { showFooterMetadata: true },
})

Enhanced Accessibility

When enhanceAccessibility: true is set in the resolved profile, the class .consenti--enhanced-a11y is added to the root element. This applies:

  • Minimum 44 × 44 px hit area on all buttons (WCAG 2.1 SC 2.5.5)
  • 3 px solid focus ring (outline-offset: 2px) on all focusable elements
  • No impact on default styling — opt-in only
profileOverride: { enhanceAccessibility: true }

Responsive Button Stacking (stackButtonsOnBreakpoint)

Set mainBanner.stackButtonsOnBreakpoint (and/or gpcBanner.stackButtonsOnBreakpoint) to a pixel width. Below that breakpoint the banner's buttons stack vertically and stretch to full width — useful for narrow mobile screens where 3+ buttons overflow.

profileOverride: {
  mainBanner: { stackButtonsOnBreakpoint: 576 },  // px; 0 or undefined = disabled
}

A scoped <style> element containing a single @media (max-width: Npx) rule is injected into the root element — no external stylesheet required, and it's removed on destroy().

Modal Focus Trap (trapFocus)

When preferenceModal.trapFocus: true, keyboard Tab / Shift+Tab navigation is confined within the consent modal while it is open. Escape closes the modal and returns focus to the triggering element.

profileOverride: {
  preferenceModal: { trapFocus: true },
}

Debugging

new ConsentiSetup({
  core: {
    console: ['error', 'warning', 'info'],  // add 'log' for verbose output
  },
})

The default is ['error'] only — no noise in production. Add levels when investigating profile resolution, GPC behavior, or domain allowlist failures. All Consenti log lines are prefixed [Consenti].


Widget API Methods

const widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })

// Consent state
widget.hasConsent()                      // boolean — true if valid consent record exists
widget.getConsent()                      // Record<string, 'granted'|'denied'|'objected'> | null
widget.getConsent('google-gtm')          // Google Consent Mode v2 format
widget.getConsent('category')            // { necessary, functional, preferences, analytics, marketing }
widget.getConsent('adobe')               // { analytics, target, manager, optimizer }
widget.getConsent('meta')                // { pixel, api, plugins, facebookLogin }
widget.getConsent('microsoft-clarity')   // { session, heatmaps, performance }
widget.getConsent('twilio-segment')      // { identify, page, track, group, alias }
widget.getGTMConsent()                   // @deprecated — use getConsent('google-gtm')
widget.getConsentDate()                  // Date | false — time of last submission
widget.isCookieGranted('analytics')      // boolean — true if that cookie is 'granted'
widget.isCookieGranted('analytics', true)// 'granted' | 'denied' | 'objected' | false
widget.isCategoryGranted('cat-analytics')          // boolean — true if ALL cookies in category are granted
widget.isCategoryGranted('cat-analytics', true)    // [{ analytics: 'granted' }, { pixel: 'denied' }]

// Visibility
widget.showBanner(gpc?)          // show main banner (or GPC variant)
widget.hideBanner()              // hide banner
widget.showModal()               // open preference modal
widget.hideModal()               // close preference modal
widget.bannerVisibility()        // 'main' | 'gpc' | false
widget.modalVisibility()         // 'preference' | false

// Bulk consent actions
widget.grantAll()                // grant all cookies and dismiss banner
widget.grantAll(true)            // grant only mandatory; deny everything else
widget.denyAll()                 // deny non-mandatory; mandatory stay 'granted'
widget.denyAll(true)             // deny all including mandatory (logs a warning)

// Actions
widget.init()                    // manually start init (when autoInit: false)
widget.onReady(callback)         // called once widget is fully initialised
widget.switchLocale(locale)      // switch locale and re-render (e.g. 'fr', 'de-AT')
widget.submitConsent(consent)    // programmatically submit consent values
widget.deleteConsent()           // delete consent record (cookie + backend if enabled)
widget.reConsent()               // delete consent and re-show banner
widget.destroy()                 // unmount DOM elements and remove all event listeners

// Logged-in user identity
widget.getUserId()               // string | null — current application user ID
widget.setUserId('user-42')      // reconsents (by default) if it differs from the stored cookie's user
widget.setUserId(null)           // clear identity (e.g. logout)
widget.setUserId('user-42', false) // update identity without reconsenting
// Or dispatch from anywhere (e.g. right after your own login flow completes):
// window.dispatchEvent(new CustomEvent('consenti:listener:identify', { detail: { userId: 'user-42' } }))

// Runtime configuration
widget.setDarkMode()             // toggle dark mode
widget.setDarkMode(true)         // force dark on
widget.setDarkMode(false)        // force light
widget.setTheme({ colorPrimary: '#ff0000' })  // hot-swap CSS tokens (merges into current theme)
widget.setConfig({ darkMode: true })          // deep-merge partial config (no re-init)
widget.setProfile({ mainBanner: { heading: 'Updated' } })  // re-render UI with new profile data

// Diagnostics
widget.version()                 // { package: '1.0.0', profileVersion: 2, consentVersion: 1 }

Consent checks

// Gate analytics code on a single cookie
if (widget.isCookieGranted('analytics_storage')) {
  initAnalytics()
}

// Read the raw status string when you need it
const status = widget.isCookieGranted('marketing', true) // 'granted' | 'denied' | 'objected' | false

// Gate a feature on an entire category (all cookies in category must be granted)
if (widget.isCategoryGranted('cat-analytics')) {
  loadHeatmaps()
}

// Inspect each cookie's status in a category
const statuses = widget.isCategoryGranted('cat-marketing', true)
// [{ ad_storage: 'granted' }, { ad_personalization: 'denied' }]

Typed event subscriptions

// Both forms are accepted — 'consenti:' prefix is optional
const handler = (data) => console.log('Consent saved:', data.consent)
widget.on('consentSubmitted', handler)

// Remove later (must pass the same function reference)
widget.off('consentSubmitted', handler)

// Available event names:
// 'bannerInitialized' | 'bannerVisibility' | 'modalVisibility'
// 'consentBeingSubmitted' | 'consentSubmitted'

Manual init

const widget = new ConsentiSetup({
  compliance: { type: 'opt-in' },
  rootEl: '#consent-mount',
  autoInit: false,
})

// Later, once mount point is in the DOM:
await widget.init()
widget.onReady(() => console.log('Ready:', widget.hasConsent()))

Programmatic consent

await widget.submitConsent({
  analytics: 'granted',
  marketing: 'denied',
  necessary: 'granted',  // mandatory cookies are always 'granted' regardless
})

// Or use the convenience methods:
await widget.grantAll()   // accept all
await widget.denyAll()    // Reject Optional non-mandatory

Events

Custom DOM events fire on window at every lifecycle step. All are prefixed consenti:.

| Event | Fired when | |--------------------------------|----------------------------------------------------------| | consenti:bannerInitialized | Widget initialises and determines banner visibility | | consenti:bannerVisibility | Banner shows or hides | | consenti:modalVisibility | Preference modal shows or hides | | consenti:consentBeingSubmitted | User clicked a consent button (before API call) | | consenti:consentSubmitted | Consent saved (cookie written + API call if configured) | | consenti:parentalConsentRequired | Age gate declined with requireParentalConsent: true — carries parentalConsentToken |

The events above are outbound (widget → host). consenti:listener:* is the opposite direction — inbound (host → widget), dispatched by your own code to tell the widget something:

| Event | Dispatch when | |--------------------------------|----------------------------------------------------------| | consenti:listener:identify | Your own login/logout flow completes — detail: { userId: string \| null, reConsent?: boolean }, equivalent to calling widget.setUserId(userId, reConsent) |

Recommended: use widget.on() / widget.off() for typed subscriptions (the consenti: prefix is optional):

const handler = (data: ConsentEvent) => console.log(data.consent)
widget.on('consentSubmitted', handler)
widget.off('consentSubmitted', handler)  // same reference to unsubscribe

Raw DOM listeners (equivalent):

window.addEventListener('consenti:consentSubmitted', (e: Event) => {
  const detail = (e as CustomEvent<ConsentEvent>).detail
})

GTM / Google Consent Mode v2

Setting utils.gtm to any object (even {}) turns on real Consent Mode signalling — no gtag.js needs to already be on the page, Consenti defines the standard stub itself.

new ConsentiSetup({
  compliance: { type: 'opt-in' },
  utils: {
    gtm: {
      containerId: 'GTM-XXXXXX', // omit if you load GTM/gtag.js yourself
      urlPassthrough: true,      // cookieless conversion modelling
      adsDataRedaction: true,    // redact ad pings when consent denied
    },
  },
})

GtmConfig options:

| Option | Type | Default | Description | |---|---|---|---| | containerId | string | undefined | GTM container ID, e.g. 'GTM-XXXXXX'. When set, Consenti injects the GTM library itself — omit if you already load GTM/gtag.js separately (Consent Mode signalling works either way). | | dataLayer | string | 'dataLayer' | Name of the dataLayer array on window. Override only for a custom variable name. | | verbose | boolean | false | When true, additionally mirrors every consenti:* event onto the dataLayer as a generic { event, content } push — for custom, non-Consent-Mode GTM triggers. | | events | string[] | [] | Only relevant when verbose: true — narrows which event names get mirrored. Empty means all. Has no effect on the core gtag('consent', …) calls, which always fire. | | urlPassthrough | boolean | false | Calls gtag('set', 'url_passthrough', true) alongside every consent update. | | adsDataRedaction | boolean | false | Calls gtag('set', 'ads_data_redaction', true) when ad_storage is denied. |

Consenti calls the real gtag() consent API — via the standard stub-queue pattern, so it works whether your own gtag.js/GTM snippet loads before or after Consenti:

// On initialisation (default denied state)
gtag('consent', 'default', { analytics_storage: 'denied', ... })

// On submission
gtag('consent', 'update', { analytics_storage: 'granted', ad_storage: 'denied', ... })

// Plus, when configured:
gtag('set', 'url_passthrough', true)
gtag('set', 'ads_data_redaction', true) // true when ad_storage is denied

Set utils.gtm.verbose: true to additionally mirror every consenti:* event onto the dataLayer as a generic { event, content } push — off by default.

getConsent('google-gtm') returns consent in Google Consent Mode v2 format. getGTMConsent() is a deprecated alias for the same call.

widget.getConsent('google-gtm')
// {
//   analytics_storage: 'granted',
//   ad_storage: 'denied',
//   ad_user_data: 'denied',
//   ad_personalization: 'denied',
//   functionality_storage: 'granted',
//   security_storage: 'granted',
//   ads_data_redaction: 'true',
//   url_passthrough: 'false',
// }

Utilities

ConsentScript — load scripts on consent

import { ConsentScript } from '@consenti/ui'

new ConsentScript({
  cookieId: 'analytics',
  src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXX',
  onLoad: () => console.log('Analytics loaded'),
  onRevoke: () => console.log('Analytics removed'),
  // bind: true (default) — auto-removes script on consent revoke, re-injects on re-grant
  // bind: false          — check consent once at construction; never auto-remove
})

ConsentAction — run a callback on a parameter's grant status

For integrations that expose their own sdk.optIn()/sdk.optOut() API (Segment, Mixpanel, Amplitude, Sentry, etc.) instead of a <script> tag — use ConsentScript for that case instead.

import { ConsentAction } from '@consenti/ui'

new ConsentAction({
  id: 'analytics_storage',
  widget,
  onGrant: () => analyticsSdk.optIn(),
  onDeny: () => analyticsSdk.optOut(),
  // bind: true (default) — re-fires on every future consent change
})

CategoryScript / CategoryAction — gate on a whole category

Same as ConsentScript/ConsentAction, but keyed on a preference-modal category instead of a single parameter — "granted" only when every parameter in the category is 'granted'.

import { CategoryScript, CategoryAction } from '@consenti/ui'

new CategoryScript({ categoryId: 'marketing', widget, src: 'https://example.com/ad-pixel.js' })

new CategoryAction({
  id: 'marketing',
  widget,
  onGrant: () => adSdk.enableAll(),
  onDeny: () => adSdk.disableAll(),
})

scanConsentScripts — declarative data-* script gating

Zero-JS integration path: mark <script> tags with data-consenti-consent-script / data-consenti-category-script and Consenti scans and wires them up automatically at the end of every init() cycle (call scanConsentScripts(widget) again manually after adding tags at runtime).

<script type="text/plain" data-consenti-consent-script="analytics_storage" src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX"></script>
<script type="text/plain" data-consenti-category-script="marketing" src="https://example.com/pixel.js"></script>
<script type="text/plain" data-consenti-consent-script="ad_storage" data-consenti-bind="false">/* inline snippet, evaluated once */</script>

BannerTrigger — open banner or modal from any element

import { BannerTrigger } from '@consenti/ui'

// Attach to existing element
new BannerTrigger({ widget, el: '#cookie-settings', action: 'modal' })

// Auto-create a button
const trigger = new BannerTrigger({ widget, action: 'modal', label: 'Manage Cookies' })
document.querySelector('#footer')?.appendChild(trigger.getElement())

Themes & CSS

CSS custom properties

Override any token in your own stylesheet:

:root {
  /* Colors */
  --consenti-color-bg: #ffffff;
  --consenti-color-text: #1a2e4a;
  --consenti-color-text-muted: #949dab;
  --consenti-color-primary: #04111f;
  --consenti-color-primary-text: #ffffff;
  --consenti-color-secondary: #f0f4f8;
  --consenti-color-secondary-text: #1a2e4a;
  --consenti-color-border: #dbe4ee;
  --consenti-color-secondary-border: #1a2e4a;
  --consenti-color-overlay: #04111f;
  --consenti-color-accent: #d32f2f;
  --consenti-color-accent-text: #ffffff;

  /* Typography */
  --consenti-font-family: system-ui, -apple-system, sans-serif;
  --consenti-font-family-mono: ui-monospace, monospace;
  --consenti-font-size-base: 14px;
  --consenti-font-size-heading: 16px;
  --consenti-font-weight-heading: 600;
  --consenti-line-height: 1.5;

  /* Spacing */
  --consenti-spacing-xs: 5px;
  --consenti-spacing-sm: 8px;
  --consenti-spacing-md: 16px;
  --consenti-spacing-lg: 24px;

  /* Shape */
  --consenti-border-radius: 8px;
  --consenti-border-radius-btn: 0;
  --consenti-shadow: 0 4px 24px rgba(21, 101, 192, 0.14);

  /* Toggle (preference modal) */
  --consenti-toggle-bg-on: #43a047;
  --consenti-toggle-bg-partial: #97c098;
  --consenti-toggle-bg-off: #9ca3af;
  --consenti-toggle-knob: #ffffff;
  --consenti-toggle-width: 52px;
  --consenti-toggle-height: 28px;

  /* Stacking */
  --consenti-z-banner: 9999;
  --consenti-z-overlay: 9998;
  --consenti-z-modal: 10000;
}

Via JS theme config

new ConsentiSetup({
  compliance: { type: 'opt-in' },
  core: {
    theme: {
      colorPrimary: '#7c3aed',       // purple brand
      colorPrimaryText: '#ffffff',
      borderRadius: '12px',
      borderRadiusBtn: '999px',   // pill buttons
      fontFamily: 'Inter, sans-serif',
    },
  },
})

Dark mode

new ConsentiSetup({
  compliance: { type: 'opt-in' },
  darkMode: true,  // or: window.matchMedia('(prefers-color-scheme: dark)').matches
})

BEM class reference

Banner:

| Class | Element | |------------------------------------|----------------------------------------------| | .consenti-banner | Banner root element | | .consenti-banner--top | Position modifier (top / middle / left-bottom / right-bottom) | | .consenti-banner--gpc | GPC banner modifier | | .consenti-banner__heading | Banner heading | | .consenti-banner__text | Banner HTML text | | .consenti-banner__buttons | Button row | | .consenti-banner__close | Close × button |

Modal:

| Class | Element | |------------------------------------|----------------------------------------------| | .consenti-overlay | Full-screen overlay backdrop | | .consenti-modal | Modal root | | .consenti-modal__heading | Modal heading | | .consenti-modal__categories | Category list | | .consenti-category | Single category block | | .consenti-category__toggle | Category enable/disable toggle | | .consenti-category__toggle--mandatory | Disabled toggle for mandatory categories | | .consenti-modal__buttons | Button row |

Buttons:

| Class | Description | |--------------------------|--------------------------| | .consenti-btn | Base button | | .consenti-btn--primary | Primary CTA (filled) | | .consenti-btn--secondary | Secondary (outlined) | | .consenti-btn--text | Text/link style | | .consenti-btn--submit | Save in modal | | .consenti-btn--manage | Opens preference modal |


Framework Guides

React

'use client'  // Next.js App Router

import { useEffect } from 'react'
import { ConsentiSetup } from '@consenti/ui'

export function ConsentSetup() {
  useEffect(() => {
    const widget = new ConsentiSetup({
      compliance: { type: 'opt-in' },
      core: { locale: 'en' },
    })
    return () => widget.destroy()
  }, [])

  return null
}

useConsent hook

import { useConsent } from '@consenti/ui/react'

export function AnalyticsButton() {
  const { hasConsent, consent, showModal } = useConsent()

  if (!hasConsent) return <button onClick={showModal}>Enable Analytics</button>

  return <span>{consent?.analytics === 'granted' ? 'Analytics on' : 'Analytics off'}</span>
}

Next.js App Router

// app/layout.tsx
import { ConsentSetup } from '@/components/ConsentSetup'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ConsentSetup />
        {children}
      </body>
    </html>
  )
}
// components/ConsentSetup.tsx
'use client'

import { useEffect, useRef } from 'react'
import type { ConsentiSetup as WidgetType } from '@consenti/ui'

export function ConsentSetup() {
  const widgetRef = useRef<WidgetType | null>(null)

  useEffect(() => {
    let widget: WidgetType
    import('@consenti/ui').then(({ ConsentiSetup }) => {
      widget = new ConsentiSetup({
        api: { enabled: true, baseUrl: process.env.NEXT_PUBLIC_API_URL },
        core: {  },
      })
      widgetRef.current = widget
    })
    return () => widgetRef.current?.destroy()
  }, [])

  return null
}

Vue 3 / Nuxt

<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'

let widget: unknown = null

onMounted(async () => {
  const { ConsentiSetup } = await import('@consenti/ui')
  widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
})

onBeforeUnmount(() => (widget as { destroy?: () => void })?.destroy?.())
</script>

Vue composable

import { useConsent } from '@consenti/ui/vue'
const { hasConsent, consent, showModal } = useConsent()

Angular

// consent.service.ts
import { Injectable, OnDestroy, inject, PLATFORM_ID } from '@angular/core'
import { isPlatformBrowser } from '@angular/common'

@Injectable({ providedIn: 'root' })
export class ConsentService implements OnDestroy {
  private platformId = inject(PLATFORM_ID)
  private widget: unknown = null

  async init() {
    if (!isPlatformBrowser(this.platformId)) return
    const { ConsentiSetup } = await import('@consenti/ui')
    this.widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
  }

  ngOnDestroy() {
    (this.widget as { destroy?: () => void })?.destroy?.()
  }
}

Or use the built-in Angular integration (setConsentiWidget() once at app root, then injectConsent() in any component/service):

import { injectConsent } from '@consenti/ui/angular'

@Component({ /* ... */ })
export class MyComponent {
  consent = injectConsent()
  // consent.hasConsent() | consent.showModal() | consent.getConsent()
  // consent.onConsentChange(cb) / onBannerChange(cb) / onModalChange(cb) — call in
  // ngOnInit, unsubscribe with the returned function in ngOnDestroy
}

Vanilla JS

import { ConsentiSetup } from '@consenti/ui'

const widget = new ConsentiSetup({ compliance: { type: 'opt-in' }, core: { locale: 'en' } })

document.querySelector('#cookie-settings')?.addEventListener('click', () => {
  widget.showModal()
})

Plugins

import { ConsentiPlugin, ConsentiSetup } from '@consenti/ui'

class MyPlugin extends ConsentiPlugin {
  initialize(widget: ConsentiSetup) {
    console.log('Consenti ready')
  }

  destroy() {}

  onConsentSubmit(consent: Record<string, string>) {
    fetch('/my-api/consent', { method: 'POST', body: JSON.stringify(consent) })
  }

  onBannerShow() {}
  onBannerHide() {}
  onModalShow() {}
  onModalHide() {}
}

new ConsentiSetup({
  compliance: { type: 'opt-in' },
  plugins: [new MyPlugin()],
})

SSR / Next.js / Nuxt

// Safe to call during SSR — silently no-ops, never touches the DOM:
const widget = new ConsentiSetup({ compliance: { type: 'opt-in' } })
// All browser API access is guarded by isClient() internally.
// SSR-safe React hook:
import { useConsent } from '@consenti/ui/react'
const { hasConsent } = useConsent()  // returns false during SSR

Testing Utilities

import {
  mockAllGranted,
  mockAllDenied,
  simulateConsentSubmitted,
} from '@consenti/ui/testing'

mockAllGranted(['analytics', 'marketing'])   // fake-grant these cookies for unit tests
mockAllDenied(['analytics', 'marketing'])    // fake-deny these cookies
simulateConsentSubmitted({ analytics: 'granted' })

Minimal Recipes

// Auto geo-route — no backend, client-side timezone + language heuristic
new ConsentiSetup({})

// CCPA opt-out for all visitors
new ConsentiSetup({ compliance: { type: 'opt-out' } })

// Cross-subdomain consent
new ConsentiSetup({ core: { storage: 'cookie', cookieDomains: '.example.com' } })

// Authenticated user — cross-device sync via API
new ConsentiSetup({
  core: { userId: '{{ server_user_id }}' },
  api: { enabled: true },
})

// GTM
new ConsentiSetup({
  core: {},
  utils: { gtm: { containerId: 'GTM-XXXXXX', adsDataRedaction: true } },
})

// Verbose debug logging during development
new ConsentiSetup({
  core: { console: ['error', 'warning', 'info', 'log'] },
})

// Arabic site — RTL derived automatically from locale
new ConsentiSetup({ core: { locale: 'ar' } })

// COPPA — block under-13s, require parental consent: set on the profile in the dashboard
// (Profile Editor Step 1 → Enable age gate → minimum age 13, require parental consent), not here.

// TCF — install the __tcfapi stub for ad-tech scripts (cmpId must match the backend's tcf config)
new ConsentiSetup({
  compliance: { tcf: { enabled: true, cmpId: 280, cmpVersion: 1 } },
})

TypeScript

All types are exported from the main entry:

import type {
  ConsentiConfig,           // top-level config object
  CoreConfig,               // core section
  ApiConfig,                // api section
  ComplianceWidgetConfig,   // compliance section
  WidgetCountryResolverFn,  // custom geo resolver function type
  UtilsConfig,              // utils section
  GtmConfig,                // utils.gtm section
  ThemeConfig,              // core.theme section
  IdentifyEventDetail,      // consenti:listener:identify event detail
  ConsentValue,             // 'granted' | 'denied' | 'objected'
  ConsentiProfile,          // local profile class
  NonEmptyArray,            // [T, ...T[]] — used by usePrebuiltProfiles
} from '@consenti/ui'

Breaking changes from v0.0.x

The following CoreConfig fields were removed in v0.1.x:

| Removed field | Replacement | |----------------------|-------------| | core.profileId | Use compliance.type with a compliance group or local profile key | | core.regulation | Use compliance.type — compliance group is derived server-side or from pre-built profiles | | core.signCookies | Implicit: set core.cookieSigningKey to enable signing; no separate flag needed | | core.privacyPolicyUrl | Add a link button directly in the profile banner/modal buttons map with action: 'link' |


Security notes

core.cookieSigningKey is spoofable in standalone mode. Without a @consenti/api backend, this key has nowhere to live but the shipped browser bundle — so anyone can read it out of your JS and re-sign a forged cookie with it. It still catches accidental tampering (e.g. a stale value from an older config), but it is not tamper-proof against a motivated attacker. If you need consent records that hold up as evidence, sign them server-side instead with compliance.dataSigningHash (@consenti/api), where the key never reaches the browser.


Contributing

See CONTRIBUTING.md in the monorepo root.


License

Apache 2.0 — see LICENSE.