Skip to content

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() 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
  • prefs is overwritten whenever the consent state changes, including changes made in another tab, by a consent adapter or by analytics.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, a sale_of_data value set elsewhere is not kept.
  • With autoShowBanner={false}, bannerVisible stays false until you call showBanner(), even for visitors who have not chosen.

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.

A stored choice does not expire by default. Two options of createAnalytics({ consent }) make ConsentLayer ask the visitor again:

  • maxAgeDays makes a choice stale once it is older than that many days. The CNIL, for example, recommends asking again after six months.
  • policyVersion makes a choice stale when it was made under another major version of your privacy policy: the part before the first ..
lib/analytics.ts
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:

lib/policy.ts
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 (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 except marketing.
  • An explicit marketing: true, from the settings dialog’s toggle and savePreferences(), or from analytics.consent.update() or setConsent(), counts as consent and is stored like any other choice.
  • Before the visitor chooses, nothing changes: consent is null, so marketing is 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:

lib/analytics.ts
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.

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.

components/consent-banner.tsx
'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>
)
}
components/consent-banner.css
.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:

components/analytics-shell.tsx
'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:

components/ConsentBanner.vue
<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.

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:

src/lib/ConsentBanner.svelte
<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:

src/lib/ConsentBanner.tsx
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.

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:

src/app/consent-banner.ts
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.

preferences, analytics and marketing are built in. To add your own, declare it in the ConsentCategories interface and pass it to createAnalytics():

lib/analytics.ts
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.