Cookie Consent
CookieConsent combines a consent sheet with a controller for storage,
reopening preferences, browser events, subscriptions, and optional server
persistence.
The block records a preference. It does not classify cookies, prevent scripts from loading, remove provider cookies, or make an application compliant by itself. The consuming application must gate each optional service from the stored preferences.
Install
pnpm dlx shadcn@latest add @blocks/cookie-consentBasic use
import {
CookieConsent,
createCookieConsentController,
createCookieConsentCookieStorage,
} from "@/components/ui/cookie-consent";
const controller = createCookieConsentController({
storage: createCookieConsentCookieStorage({
days: 180,
sameSite: "Lax",
secure: process.env.NODE_ENV === "production",
}),
});
export function CookieConsentProvider() {
return <CookieConsent controller={controller} />;
}Dependencies
| Kind | Packages |
|---|---|
| Registry | @circle-ui/button, @circle-ui/sheet, @circle-ui/switch |
| Copy packs | cookie-consent-copy |
Create one controller and pass the same instance to the sheet and to any code
that reads consent. Without an explicit controller, the component creates one
that uses window.localStorage.
Lifecycle
- On mount, the component reads the configured storage key.
- When a valid record exists, it restores the choices and emits a
hydratechange. It does not auto-open the sheet. - When no valid record exists,
autoOpenopens the sheet by default. - Accepting, rejecting, or saving a selection forces required categories to
true, writes the record, emits change events, calls the persistence hook, and notifies subscribers. openPreferences()reopens the sheet on its settings screen with the latest stored choices.
Stored records are normalized against the current categories whenever they are read. Removed category ids are dropped, new ids receive their configured default, and required categories remain enabled.
Production setup
Use the cookie adapter when the preference must be available across page loads as a cookie or shared by subdomains. Guard the shared domain so local and preview hosts keep a host-only cookie.
"use client";
import {
CookieConsent,
createCookieConsentController,
createCookieConsentCookieStorage,
type CookieConsentCategory,
} from "@/components/ui/cookie-consent";
const categories: CookieConsentCategory[] = [
{
id: "essential",
label: "Essential",
description: "Required to provide the requested service.",
required: true,
},
{
id: "analytics",
label: "Analytics",
description: "Helps us understand product usage.",
defaultValue: false,
},
{
id: "marketing",
label: "Marketing",
description: "Measures and personalizes campaigns.",
defaultValue: false,
},
];
function resolveCookieDomain(location: Location) {
const hostname = location.hostname.toLowerCase();
if (hostname === "circle.health" || hostname.endsWith(".circle.health")) {
return ".circle.health";
}
return null;
}
export const cookieConsent = createCookieConsentController({
categories,
storage: createCookieConsentCookieStorage({
days: 180,
domain: resolveCookieDomain,
sameSite: "Lax",
secure: process.env.NODE_ENV === "production",
}),
storageKey: "ch_cc",
version: "1",
});
export function CookieConsentProvider() {
return (
<CookieConsent
controller={cookieConsent}
policyLink={{
href: "/privacy",
label: "privacy and cookie policy",
}}
/>
);
}Render the provider once near the application root. Keep category ids stable: they are the keys consumed by analytics, marketing, and personalization integrations.
Cookie storage options
| Option | Default | Behavior |
|---|---|---|
days | 365 | Lifetime of the preference cookie. |
domain | — | A domain string or a function of window.location. Returning null creates a host-only cookie. |
path | / | Cookie path. |
sameSite | — | Lax, Strict, or None. None also enables Secure. |
secure | false | Adds the Secure attribute. Enable it on HTTPS production hosts. |
The adapter is client-side and cannot create an HttpOnly cookie. When a
domain is configured, it also removes an older host-only cookie so that one
unambiguous record remains.
Categories and stored preferences
Category fields
| Field | Required | Behavior |
|---|---|---|
id | Yes | Stable key written to consents and included in events. |
label | Yes | Label for an optional category switch. |
description | No | Metadata included with events; the current sheet does not render it. |
required | No | Forces the category to true. Required categories are summarized by the Essential row instead of receiving switches. |
defaultValue | No | Initial value before a choice exists. Required categories always resolve to true. |
The built-in categories are a starter configuration, not a policy. Define the categories owned by the consuming application.
Stored record
The default storage key is ch_cc. Both local storage and cookie storage use
the same JSON record:
{
"id": "8a9f9f61-cfdd-4556-9ed3-7537d7c7e580",
"version": "1",
"updatedAt": "2026-07-24T08:00:00.000Z",
"consents": {
"essential": true,
"analytics": false,
"marketing": false
}
}The id is retained when an existing preference is updated. updatedAt
records the latest user choice. Increment version when a policy or category
change requires a new choice; a stored record with a different version is
treated as absent.
Gate optional services
Read the controller before loading an optional service, and react to later
changes. Mounting the sheet with an existing record dispatches the current
preferences with source: "hydrate".
const cookieConsent = createCookieConsentController({
onPreferencesChange({ consents, source }) {
if (consents.analytics) {
startAnalytics();
} else {
stopAnalytics();
}
console.info("Consent applied", source);
},
});onPreferencesChange runs for a stored choice and for hydration. It is the
most direct integration point when the controller and service bootstrap live
in the same application.
Browser events
| Event | Target | Timing |
|---|---|---|
cookies-chosen | document | Immediately after a choice or hydration. |
cookies-chosen-processed | document | One second later by default, for integrations that need a second phase. |
ch-open-cookie-preferences | window | Opens the mounted sheet on its settings screen. |
The two change events are CustomEvent<CookieConsentEventDetail> events. Their
detail contains categories, consents, the complete preferences record,
source, and version.
import type { CookieConsentEventDetail } from "@/components/ui/cookie-consent";
document.addEventListener("cookies-chosen", (event) => {
const { consents } = (event as CustomEvent<CookieConsentEventDetail>).detail;
if (consents.analytics) startAnalytics();
});Set emitDataLayerEvents: true to also push { event: eventName } to
window.dataLayer, or set dataLayerName for another array. Data-layer entries
contain the event name only; use the callback or DOM event when the integration
needs the preference detail.
Let people revisit their choice
Keep a cookie-settings action available after the sheet closes.
export function CookieSettingsButton() {
return (
<button type="button" onClick={() => cookieConsent.openPreferences()}>
Cookie settings
</button>
);
}Code without the controller can dispatch the exported default event:
import { DEFAULT_OPEN_PREFERENCES_EVENT } from "@/components/ui/cookie-consent";
window.dispatchEvent(new CustomEvent(DEFAULT_OPEN_PREFERENCES_EVENT));The event only has an effect while a CookieConsent component is mounted.
Optional window bridge
Legacy scripts and tag-manager code can use a global bridge instead of importing the controller. Attach it from a client component and return the cleanup function from the effect.
import { useEffect } from "react";
import { attachCookieConsentBridge } from "@/components/ui/cookie-consent";
export function CookieConsentBridge() {
useEffect(() => attachCookieConsentBridge(window, cookieConsent), []);
return null;
}The default global is window.CH_CC. It exposes consents, get, set,
update, subscribe, delete, reset, openPreferences, and
dispatchCurrentPreferences. Pass a third argument to change the global name.
Cleanup restores any value that existed there before the bridge was attached.
Persist an audit record
persist receives the same event detail plus storageKey and optional
persistText. It runs only after a user choice, not during hydration.
const cookieConsent = createCookieConsentController({
persistText: "Cookie consent v3",
async persist(payload) {
await fetch("/api/consent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
},
});Configured client storage is written first. Persistence is fire-and-forget: a rejected promise logs an error and does not roll back the local preference or keep the sheet open. Handle authentication, retries, and server validation in the application when the audit record is required.
Controller reference
| Method | Behavior |
|---|---|
get() | Returns the normalized preference record or null. |
getConsents() | Returns only the consent map or null. |
set(consents, source) | Replaces optional choices, writes the record, and emits a change. |
update(consents, source?) | Merges a partial consent map; the default source is save-selection. |
dispatchCurrentPreferences() | Re-emits the stored record with source hydrate. |
openPreferences() | Dispatches the configured window event to open settings. |
subscribe(listener) | Observes writes, hydration, reset, and deletion. Returns an unsubscribe function. |
reset() | Removes the stored record and notifies subscribers with null. |
delete() | Removes the stored record and notifies subscribers; currently the same as reset(). |
Neither reset() nor delete() emits a consent change event or removes cookies
owned by other services.
Controller options
| Option | Default | Behavior |
|---|---|---|
categories | Built-in categories | Categories normalized and owned by this controller. |
storage | window.localStorage | Any synchronous getItem, setItem, and removeItem adapter. |
storageKey | ch_cc | Key passed to the storage adapter. |
version | 1 | Record version used to accept or invalidate stored preferences. |
chosenEventName | cookies-chosen | Immediate document event and optional data-layer event. |
processedEventName | cookies-chosen-processed | Delayed document event and optional data-layer event. |
processedDelayMs | 1000 | Delay before the processed event. |
openPreferencesEventName | ch-open-cookie-preferences | Window event observed by the sheet. |
onPreferencesChange | — | Synchronous callback for choices and hydration. |
persist | — | Optional asynchronous audit hook for user choices. |
persistText | — | Application-defined text copied into the persistence payload. |
emitDataLayerEvents | false | Pushes the chosen and processed event names to a data layer. |
dataLayerName | dataLayer | Property on window that receives data-layer events. |
Component props
| Prop | Default | Behavior |
|---|---|---|
controller | Internal controller | Preferred way to share preferences and configuration with the application. |
categories | Built-in categories | Used only when the component creates its internal controller. |
autoOpen | true | Opens on first mount when no valid preference exists. |
open | — | Controlled open state. The owner must update it from onOpenChange. |
defaultOpen | false | Initial open state when uncontrolled. |
onOpenChange | — | Called for automatic, consent-action, dismissal, and open-preferences requests. |
initialScreen | main | Screen restored after the sheet closes: main or settings. |
dismissible | false | Whether the sheet can close without a consent action. |
title | English default | Sheet heading. |
description | English default | Custom body copy. Takes precedence over the generated policy-link sentence. |
policyLink | — | Link inserted into the default English description. |
labels | English defaults | Partial overrides for buttons, Essential, Required, and the settings aria-label. |
className | — | Class names applied to the sheet’s inner content wrapper. |
Localization
The optional copy pack provides English and German dictionaries. It is data, not an automatic locale provider; map the selected dictionary into categories, labels, title, and description in the consuming application.
pnpm dlx shadcn@latest add @i18n/cookie-consent-copyimport { cookieConsentCopy } from "@/registry/berlin/i18n/cookie-consent-copy";
const copy = cookieConsentCopy.de;
<CookieConsent
controller={cookieConsent}
title={copy.title}
labels={{
acceptAll: copy.buttons.acceptAll,
back: copy.buttons.back,
customize: copy.buttons.customize,
essential: copy.settings.essential,
rejectAll: copy.buttons.rejectAll,
required: copy.settings.required,
saveSelection: copy.buttons.saveSelection,
settingsAriaLabel: copy.settings.ariaLabel,
}}
/>;The {link} token in subtitle is an application interpolation point. The
component does not replace it automatically.