Consent UI
The npm package does not ship a consent banner, so its design, wording and
languages remain yours; only the script tag
comes with one. This page documents useConsentController(), the hook
that provides a banner’s state and actions, gives a complete banner and
settings dialog to copy into your app, and explains how to add consent
categories of your own. The recipe is the banner the
Playground runs.
useConsentController()
Section titled “useConsentController()”useConsentController() is exported from consentlayer/react. Its state is
held by <AnalyticsProvider>, so it must be called in a descendant of the
provider; elsewhere it throws
useConsentController must be used within AnalyticsProvider. Unlike
useAnalytics() and useConsent(), it has no fallback to the most recent
instance.
| Field | Type | Behavior |
|---|---|---|
ready |
boolean |
false until analytics.consent.bootstrap() has settled. Render nothing while it is false, so that returning visitors don’t see the banner flash. |
error |
string | null |
The rejection reason, converted to a string, if bootstrap() rejected. The banner is then shown as if no consent were stored. |
bannerVisible |
boolean |
After bootstrap, true when no consent is stored or the stored choice is stale, and the provider’s autoShowBanner prop is true (the default). |
dialogVisible |
boolean |
Whether the settings dialog is open. Initially false. |
categories |
readonly ConsentCategory[] |
The categories the dialog shows, from analytics.consent.categories: the built-in ones, then your custom ones. |
prefs |
Record<ConsentCategory, boolean> |
The dialog’s draft, one entry per category in analytics.consent.categories. Initialized from the stored consent, all false when there is none. |
setPrefs(next) |
(next) => void |
Replaces the draft without saving it. |
gpc |
boolean |
Whether the visitor’s Global Privacy Control signal is honored. Use it to tell the visitor so. |
acceptAll() |
() => Promise<void> |
Grants every category, then hides the banner and the dialog. While gpc is true, marketing stays denied. |
rejectNonEssential() |
() => Promise<void> |
Denies every category, then hides both. |
savePreferences() |
() => Promise<void> |
Saves prefs, then hides both. |
openDialog(), closeDialog() |
() => void |
Show or hide the dialog. Closing it discards toggles that were not saved. |
showBanner(), hideBanner() |
() => void |
Show or hide the banner. |
The three saving actions call analytics.consent.setConsent() and hide the
banner and dialog only once it resolves, which is after the consent adapters
and the server.consentEndpoint request have finished.
Edge cases
prefsis overwritten whenever the consent state changes, including changes made in another tab, by a consent adapter or byanalytics.consent.update(). A draft the visitor has not saved is lost when that happens.- The saving actions write only the toggleable categories. Because
setConsent()replaces the whole state, asale_of_datavalue set elsewhere is not kept. - With
autoShowBanner={false},bannerVisiblestaysfalseuntil you callshowBanner(), even for visitors who have not chosen.
Reopening the settings
Section titled “Reopening the settings”Visitors must be able to change their choice later. Inside React, call
openDialog(), as the recipe’s “Cookie settings” button does below. Outside
React, for example in a footer rendered by a CMS, call the open-settings
global, which <AnalyticsProvider> defines when it mounts:
<button type="button" onclick="window.CONSENTLAYER_openConsent?.()">Cookie settings</button>The global’s name follows names.prefix. With
names.legacy, ACM_openConsent is defined as well. The
openConsentGlobalKey prop of <AnalyticsProvider> replaces both with the
name you pass.
Asking again
Section titled “Asking again”A stored choice does not expire by default. Two options of
createAnalytics({ consent }) make ConsentLayer ask the visitor again:
maxAgeDaysmakes a choice stale once it is older than that many days. The CNIL, for example, recommends asking again after six months.policyVersionmakes a choice stale when it was made under another major version of your privacy policy: the part before the first..
export const analytics = createAnalytics({ providers, consent: { maxAgeDays: 180, policyVersion: POLICY_VERSION },})Version your privacy policy as major.minor and keep the version in one
constant that the browser and the consent route
both import:
export const POLICY_VERSION = '3.2'Raise the major version when the policy changes in a way visitors must agree to again, such as a new purpose or a new recipient of their data. Raise the minor version for edits that need no new consent, such as a new contact address:
| Change | Visitors asked again | New records cite |
|---|---|---|
'3.1' to '3.2' |
No | '3.2' |
'3.2' to '4.0' |
Yes | '4.0' |
A choice made under '3.1' stays valid under '3.2', and its record keeps
citing '3.1', the version the visitor agreed to. A version without a .,
such as a date like '2026-10-01', is entirely the major version, so any
change asks again.
Each choice is stored with the date it was made and the policyVersion in
effect. When bootstrap() finds a stale choice, bannerVisible becomes
true and analytics.consent.isStale() returns true. What the stale choice
means until the visitor answers depends on onStale:
onStale |
Consent until the visitor chooses | Providers |
|---|---|---|
'discard' (default) |
null, as for a first visit |
Only those with requiredConsent: 'none' run. |
'keep' |
The stale choice | Unchanged. The banner is shown on top. |
With 'keep', prefs starts from the stale choice, so a visitor who confirms
it without changes stores the same choice again. That confirmation counts as a
new choice: it is stored with the current date and policyVersion and, with
server.consentEndpoint, recorded again on the server.
Choices stored without a date or version
A choice stored before these options existed, or before you first set them,
carries no date or no policyVersion. It counts as stale for each option you
set, so turning on maxAgeDays or policyVersion asks every returning
visitor once. Choices supplied by a consent adapter, such as Shopify Customer
Privacy, are stored with the current date and version, since the adapter is
their source.
Global Privacy Control
Section titled “Global Privacy Control”Global Privacy Control (GPC) is a browser signal by which a visitor asks sites
not to sell or share their data. A browser that sends it exposes
navigator.globalPrivacyControl as true. ConsentLayer honors it by keeping
the marketing category off unless the visitor switches it on:
- The banner is shown as usual. Analytics, preferences and custom categories follow the visitor’s choice.
acceptAll()grants every category exceptmarketing.- An explicit
marketing: true, from the settings dialog’s toggle andsavePreferences(), or fromanalytics.consent.update()orsetConsent(), counts as consent and is stored like any other choice. - Before the visitor chooses, nothing changes: consent is
null, somarketingis already denied.
gpc on useConsentController(), and analytics.consent.gpc, are true
while the signal is honored, so your banner can say so:
{gpc && <p>Your browser's Global Privacy Control signal is on, so marketing is off.</p>}To ignore the signal, set gpc: false:
export const analytics = createAnalytics({ providers, consent: { gpc: false } })Stored choices and consent adapters
The signal is read once, when createAnalytics() runs, and only changes what
“Accept all” grants. A choice already stored, or one a consent adapter such
as Shopify Customer Privacy supplies at
bootstrap, was made by the visitor and is kept as it is, marketing
included. The Shopify adapter supplies a choice only when the visitor has
made one in Shopify.
The server follows the consent each event carries, so it matches the browser; see Server-side tracking.
Recipe
Section titled “Recipe”Copy both files, then adapt the wording and the styles. The categories
object needs a label and description for every category in
ConsentCategory, and TypeScript rejects it if one is missing or undeclared.
functional is the Playground’s custom category; delete
its entry unless you declare it. The “Necessary” row is not a category, since
necessary cookies need no consent, so the dialog renders it checked and
disabled.
'use client'
import type { ConsentCategory } from 'consentlayer'import { useConsentController } from 'consentlayer/react'import './consent-banner.css'
const categories: Record<ConsentCategory, { label: string; description: string }> = { preferences: { label: 'Preferences', description: 'Remember settings such as language or region.' }, analytics: { label: 'Analytics', description: 'Help us understand how the site is used.' }, marketing: { label: 'Marketing', description: 'Measure our ads and show relevant ones on other sites.' }, functional: { label: 'Functional', description: 'Enable extras such as the support chat.' },}
export function ConsentBanner({ privacyPolicyUrl }: { privacyPolicyUrl?: string }) { const consent = useConsentController() if (!consent.ready) return null
if (consent.dialogVisible) { return ( <section className="consent-dialog" role="dialog" aria-labelledby="consent-dialog-title"> <h2 id="consent-dialog-title">Cookie settings</h2> <label> <input type="checkbox" checked disabled /> <span> <strong>Necessary</strong> Needed for the site to work, such as remembering this choice. Always on. </span> </label> {consent.categories.map((key) => ( <label key={key}> <input type="checkbox" checked={consent.prefs[key]} onChange={(event) => consent.setPrefs({ ...consent.prefs, [key]: event.target.checked })} /> <span> <strong>{categories[key].label}</strong> {categories[key].description} </span> </label> ))} <div className="consent-actions"> <button type="button" onClick={consent.closeDialog}> Close </button> <button type="button" className="primary" onClick={() => void consent.savePreferences()}> Save </button> </div> </section> ) }
if (!consent.bannerVisible) return null return ( <section className="consent-banner" role="dialog" aria-labelledby="consent-banner-title"> <h2 id="consent-banner-title">Cookies</h2> <p> We use cookies to run this site and, with your consent, for preferences, analytics and marketing. {privacyPolicyUrl && ( <> {' '} <a href={privacyPolicyUrl}>Privacy policy</a> </> )} </p> <div className="consent-actions"> <button type="button" onClick={() => void consent.rejectNonEssential()}> Reject non-essential </button> <button type="button" onClick={consent.openDialog}> Customize </button> <button type="button" className="primary" onClick={() => void consent.acceptAll()}> Accept all </button> </div> </section> )}.consent-banner,.consent-dialog { position: fixed; z-index: 1000; display: grid; gap: 0.75rem; padding: 1.25rem; border: 1px solid #e5e7eb; border-radius: 0.75rem; background: #fff; color: #111827; box-shadow: 0 10px 30px rgb(0 0 0 / 0.12); font: 0.95rem/1.45 system-ui, sans-serif;}.consent-banner { inset: auto 1rem 1rem; max-width: 40rem; margin-inline: auto;}.consent-dialog { top: 50%; left: 50%; width: min(30rem, calc(100vw - 2rem)); transform: translate(-50%, -50%);}.consent-banner h2,.consent-dialog h2 { margin: 0; color: inherit; font-size: 1.1rem;}.consent-banner p { margin: 0;}.consent-banner a { color: inherit;}.consent-dialog label { display: flex; gap: 0.6rem; align-items: start;}.consent-dialog input { margin-top: 0.3rem; accent-color: #111827;}.consent-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem;}.consent-actions button { padding: 0.5rem 0.9rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: transparent; color: inherit; font: inherit; cursor: pointer;}.consent-actions .primary { border-color: #111827; background: #111827; color: #fff;}Render the banner once inside the provider, and give visitors a way back to the settings:
'use client'
import { AnalyticsProvider, useConsentController } from 'consentlayer/react'import type { ReactNode } from 'react'import { analytics } from '../lib/analytics'import { ConsentBanner } from './consent-banner'
function CookieSettingsButton() { const { openDialog } = useConsentController() return ( <button type="button" onClick={openDialog}> Cookie settings </button> )}
export function AnalyticsShell({ children }: { children: ReactNode }) { return ( <AnalyticsProvider analytics={analytics}> {children} <ConsentBanner privacyPolicyUrl="/privacy" /> <CookieSettingsButton /> </AnalyticsProvider> )}In a Next.js app, import AnalyticsProvider from consentlayer/next instead
so page views follow navigation, as shown in Next.js. The banner’s text is plain strings in your
component, so translate it with the i18n library your app already uses.
useConsentController() from consentlayer/vue has the same fields, with the
state as read-only refs: read bannerVisible.value in <script>, and
bannerVisible in the template. It throws
useConsentController needs app.use(consentLayer) when the plugin is not
installed. This component uses the same CSS file as the React recipe:
<script setup lang="ts">import type { ConsentCategory } from 'consentlayer'import { useConsentController } from 'consentlayer/vue'import './consent-banner.css'
defineProps<{ privacyPolicyUrl?: string }>()
const labels: Record<ConsentCategory, { label: string; description: string }> = { preferences: { label: 'Preferences', description: 'Remember settings such as language or region.' }, analytics: { label: 'Analytics', description: 'Help us understand how the site is used.' }, marketing: { label: 'Marketing', description: 'Measure our ads and show relevant ones on other sites.' },}
const { ready, bannerVisible, dialogVisible, categories, prefs, setPrefs, openDialog, closeDialog, acceptAll, rejectNonEssential, savePreferences,} = useConsentController()</script>
<template> <section v-if="ready && dialogVisible" class="consent-dialog" role="dialog" aria-labelledby="consent-dialog-title"> <h2 id="consent-dialog-title">Cookie settings</h2> <label> <input type="checkbox" checked disabled /> <span><strong>Necessary</strong> Needed for the site to work, such as remembering this choice. Always on.</span> </label> <label v-for="key in categories" :key="key"> <input type="checkbox" :checked="prefs[key]" @change="setPrefs({ ...prefs, [key]: ($event.target as HTMLInputElement).checked })" /> <span ><strong>{{ labels[key].label }}</strong> {{ labels[key].description }}</span > </label> <div class="consent-actions"> <button type="button" @click="closeDialog">Close</button> <button type="button" class="primary" @click="savePreferences">Save</button> </div> </section> <section v-else-if="ready && bannerVisible" class="consent-banner" role="dialog" aria-labelledby="consent-banner-title" > <h2 id="consent-banner-title">Cookies</h2> <p> We use cookies to run this site and, with your consent, for preferences, analytics and marketing. <a v-if="privacyPolicyUrl" :href="privacyPolicyUrl">Privacy policy</a> </p> <div class="consent-actions"> <button type="button" @click="rejectNonEssential">Reject non-essential</button> <button type="button" @click="openDialog">Customize</button> <button type="button" class="primary" @click="acceptAll">Accept all</button> </div> </section></template>Vue and Nuxt show where to render it.
Svelte
Section titled “Svelte”useConsentController() from consentlayer/svelte and consentlayer/sveltekit has the same fields, with
the state as reactive properties: read consent.bannerVisible rather than
destructuring it. It throws
useConsentController needs consentLayer() in a parent component when no
parent component called consentLayer(). This component uses the same CSS
file as the React recipe:
<script lang="ts"> import type { ConsentCategory } from 'consentlayer' import { useConsentController } from 'consentlayer/sveltekit' import './consent-banner.css'
let { privacyPolicyUrl }: { privacyPolicyUrl?: string } = $props()
const labels: Record<ConsentCategory, { label: string; description: string }> = { preferences: { label: 'Preferences', description: 'Remember settings such as language or region.' }, analytics: { label: 'Analytics', description: 'Help us understand how the site is used.' }, marketing: { label: 'Marketing', description: 'Measure our ads and show relevant ones on other sites.' }, }
const consent = useConsentController()</script>
{#if consent.ready && consent.dialogVisible} <div class="consent-dialog" role="dialog" aria-labelledby="consent-dialog-title"> <h2 id="consent-dialog-title">Cookie settings</h2> <label> <input type="checkbox" checked disabled /> <span><strong>Necessary</strong> Needed for the site to work, such as remembering this choice. Always on.</span> </label> {#each consent.categories as key (key)} <label> <input type="checkbox" checked={consent.prefs[key]} onchange={(event) => consent.setPrefs({ ...consent.prefs, [key]: event.currentTarget.checked })} /> <span><strong>{labels[key].label}</strong> {labels[key].description}</span> </label> {/each} <div class="consent-actions"> <button type="button" onclick={consent.closeDialog}>Close</button> <button type="button" class="primary" onclick={consent.savePreferences}>Save</button> </div> </div>{:else if consent.ready && consent.bannerVisible} <div class="consent-banner" role="dialog" aria-labelledby="consent-banner-title"> <h2 id="consent-banner-title">Cookies</h2> <p> We use cookies to run this site and, with your consent, for preferences, analytics and marketing. {#if privacyPolicyUrl}<a href={privacyPolicyUrl}>Privacy policy</a>{/if} </p> <div class="consent-actions"> <button type="button" onclick={consent.rejectNonEssential}>Reject non-essential</button> <button type="button" onclick={consent.openDialog}>Customize</button> <button type="button" class="primary" onclick={consent.acceptAll}>Accept all</button> </div> </div>{/if}Without SvelteKit, import from consentlayer/svelte instead.
Svelte & SvelteKit
shows where to render it.
useConsentController() from consentlayer/solid and consentlayer/solidstart
has the same fields, with the state as reactive properties: read
consent.bannerVisible in JSX rather than destructuring it. It throws
useConsentController must be used within AnalyticsProvider when no
<AnalyticsProvider> is above the component. This component uses the same CSS
file as the React recipe:
import type { ConsentCategory } from 'consentlayer'import { useConsentController } from 'consentlayer/solidstart'import { For, Show } from 'solid-js'import './consent-banner.css'
const labels: Record<ConsentCategory, { label: string; description: string }> = { preferences: { label: 'Preferences', description: 'Remember settings such as language or region.' }, analytics: { label: 'Analytics', description: 'Help us understand how the site is used.' }, marketing: { label: 'Marketing', description: 'Measure our ads and show relevant ones on other sites.' },}
export function ConsentBanner(props: { privacyPolicyUrl?: string }) { const consent = useConsentController()
return ( <Show when={consent.ready}> <Show when={consent.dialogVisible} fallback={ <Show when={consent.bannerVisible}> <div class="consent-banner" role="dialog" aria-labelledby="consent-banner-title"> <h2 id="consent-banner-title">Cookies</h2> <p> We use cookies to run this site and, with your consent, for preferences, analytics and marketing.{' '} <Show when={props.privacyPolicyUrl}> <a href={props.privacyPolicyUrl}>Privacy policy</a> </Show> </p> <div class="consent-actions"> <button type="button" onClick={() => void consent.rejectNonEssential()}> Reject non-essential </button> <button type="button" onClick={() => consent.openDialog()}> Customize </button> <button type="button" class="primary" onClick={() => void consent.acceptAll()}> Accept all </button> </div> </div> </Show> } > <div class="consent-dialog" role="dialog" aria-labelledby="consent-dialog-title"> <h2 id="consent-dialog-title">Cookie settings</h2> <label> <input type="checkbox" checked disabled /> <span> <strong>Necessary</strong> Needed for the site to work, such as remembering this choice. Always on. </span> </label> <For each={consent.categories}> {(key) => ( <label> <input type="checkbox" checked={consent.prefs[key]} onChange={(event) => consent.setPrefs({ ...consent.prefs, [key]: event.currentTarget.checked })} /> <span> <strong>{labels[key].label}</strong> {labels[key].description} </span> </label> )} </For> <div class="consent-actions"> <button type="button" onClick={() => consent.closeDialog()}> Close </button> <button type="button" class="primary" onClick={() => void consent.savePreferences()}> Save </button> </div> </div> </Show> </Show> )}Without Solid Router, import from consentlayer/solid instead.
Solid & SolidStart
shows where to render it.
Angular
Section titled “Angular”injectConsentController() from consentlayer/angular has the same fields,
with the state as signals: call consent.bannerVisible() in the template. It
throws injectConsentController needs provideConsentLayer() when
provideConsentLayer() is not in the application config. This component uses
the same CSS file as the React recipe, added to styles in angular.json:
import { Component, input } from '@angular/core'import type { ConsentCategory } from 'consentlayer'import { injectConsentController } from 'consentlayer/angular'
const labels: Record<ConsentCategory, { label: string; description: string }> = { preferences: { label: 'Preferences', description: 'Remember settings such as language or region.', }, analytics: { label: 'Analytics', description: 'Help us understand how the site is used.', }, marketing: { label: 'Marketing', description: 'Measure our ads and show relevant ones on other sites.', },}
@Component({ selector: 'app-consent-banner', template: ` @if (consent.ready()) { @if (consent.dialogVisible()) { <div class="consent-dialog" role="dialog" aria-labelledby="consent-dialog-title"> <h2 id="consent-dialog-title">Cookie settings</h2> <label> <input type="checkbox" checked disabled /> <span ><strong>Necessary</strong> Needed for the site to work, such as remembering this choice. Always on.</span > </label> @for (key of consent.categories(); track key) { <label> <input type="checkbox" [checked]="consent.prefs()[key]" (change)="toggle(key, $event)" /> <span ><strong>{{ labels[key].label }}</strong> {{ labels[key].description }}</span > </label> } <div class="consent-actions"> <button type="button" (click)="consent.closeDialog()">Close</button> <button type="button" class="primary" (click)="consent.savePreferences()">Save</button> </div> </div> } @else if (consent.bannerVisible()) { <div class="consent-banner" role="dialog" aria-labelledby="consent-banner-title"> <h2 id="consent-banner-title">Cookies</h2> <p> We use cookies to run this site and, with your consent, for preferences, analytics and marketing. @if (privacyPolicyUrl()) { <a [href]="privacyPolicyUrl()">Privacy policy</a> } </p> <div class="consent-actions"> <button type="button" (click)="consent.rejectNonEssential()">Reject non-essential</button> <button type="button" (click)="consent.openDialog()">Customize</button> <button type="button" class="primary" (click)="consent.acceptAll()">Accept all</button> </div> </div> } } `,})export class ConsentBanner { readonly privacyPolicyUrl = input<string>() protected readonly consent = injectConsentController() protected readonly labels = labels
protected toggle(key: ConsentCategory, event: Event): void { this.consent.setPrefs({ ...this.consent.prefs(), [key]: (event.target as HTMLInputElement).checked, }) }}Angular shows where to render it.
Custom categories
Section titled “Custom categories”preferences, analytics and marketing are built in. To add your own,
declare it in the ConsentCategories interface and pass it to
createAnalytics():
import { createAnalytics, definePlugin, loadScript } from 'consentlayer'
declare module 'consentlayer' { interface ConsentCategories { functional: true }}
const supportChat = definePlugin({ id: 'support-chat', requiredConsent: 'functional', load: () => loadScript('https://chat.example.com/widget.js'),})
export const analytics = createAnalytics({ categories: ['functional'], providers: [supportChat],})A custom category behaves like a built-in one. It is denied until granted,
stored, broadcast and posted to the server.consentEndpoint. “Accept all”
grants it and “Reject non-essential” denies it. analytics.consent.categories
lists it after the built-in categories, in the order of the categories option, and the
recipe’s dialog shows a toggle for it in that order. Any plugin can require it
with requiredConsent.
Both steps are needed. Without the declaration, categories: ['functional']
and requiredConsent: 'functional' don’t compile. Without the categories
option, the category is dropped whenever consent is normalized, so it is never
stored and a plugin that requires it never loads.
If you record consent on the server, pass the same list to
createConsentHandler({ categories }). The handler otherwise drops the custom
categories from the record. See Consent records.