Skip to Content
BlocksCookie Consent

Cookie Consent

CookieConsent combines a consent sheet with a controller for storage, reopening preferences, browser events, subscriptions, and optional server persistence.

Loading preview…

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

Terminal
pnpm dlx shadcn@latest add @blocks/cookie-consent

Basic use

TSX
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

KindPackages
Registry@circle-ui/button, @circle-ui/sheet, @circle-ui/switch
Copy packscookie-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

  1. On mount, the component reads the configured storage key.
  2. When a valid record exists, it restores the choices and emits a hydrate change. It does not auto-open the sheet.
  3. When no valid record exists, autoOpen opens the sheet by default.
  4. Accepting, rejecting, or saving a selection forces required categories to true, writes the record, emits change events, calls the persistence hook, and notifies subscribers.
  5. 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.

cookie-consent-provider.tsx
"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.

OptionDefaultBehavior
days365Lifetime of the preference cookie.
domainA domain string or a function of window.location. Returning null creates a host-only cookie.
path/Cookie path.
sameSiteLax, Strict, or None. None also enables Secure.
securefalseAdds 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

FieldRequiredBehavior
idYesStable key written to consents and included in events.
labelYesLabel for an optional category switch.
descriptionNoMetadata included with events; the current sheet does not render it.
requiredNoForces the category to true. Required categories are summarized by the Essential row instead of receiving switches.
defaultValueNoInitial 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

EventTargetTiming
cookies-chosendocumentImmediately after a choice or hydration.
cookies-chosen-processeddocumentOne second later by default, for integrations that need a second phase.
ch-open-cookie-preferenceswindowOpens 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

MethodBehavior
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

OptionDefaultBehavior
categoriesBuilt-in categoriesCategories normalized and owned by this controller.
storagewindow.localStorageAny synchronous getItem, setItem, and removeItem adapter.
storageKeych_ccKey passed to the storage adapter.
version1Record version used to accept or invalidate stored preferences.
chosenEventNamecookies-chosenImmediate document event and optional data-layer event.
processedEventNamecookies-chosen-processedDelayed document event and optional data-layer event.
processedDelayMs1000Delay before the processed event.
openPreferencesEventNamech-open-cookie-preferencesWindow event observed by the sheet.
onPreferencesChangeSynchronous callback for choices and hydration.
persistOptional asynchronous audit hook for user choices.
persistTextApplication-defined text copied into the persistence payload.
emitDataLayerEventsfalsePushes the chosen and processed event names to a data layer.
dataLayerNamedataLayerProperty on window that receives data-layer events.

Component props

PropDefaultBehavior
controllerInternal controllerPreferred way to share preferences and configuration with the application.
categoriesBuilt-in categoriesUsed only when the component creates its internal controller.
autoOpentrueOpens on first mount when no valid preference exists.
openControlled open state. The owner must update it from onOpenChange.
defaultOpenfalseInitial open state when uncontrolled.
onOpenChangeCalled for automatic, consent-action, dismissal, and open-preferences requests.
initialScreenmainScreen restored after the sheet closes: main or settings.
dismissiblefalseWhether the sheet can close without a consent action.
titleEnglish defaultSheet heading.
descriptionEnglish defaultCustom body copy. Takes precedence over the generated policy-link sentence.
policyLinkLink inserted into the default English description.
labelsEnglish defaultsPartial overrides for buttons, Essential, Required, and the settings aria-label.
classNameClass 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.

Terminal
pnpm dlx shadcn@latest add @i18n/cookie-consent-copy
import { 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.

Last updated on