Cookie Banner for shadcn/ui
A simple, full-service cookie consent solution for React β built entirely from shadcn/ui primitives and installed from a shadcn registry.
This handles displaying your cookie banner, saving and managing user consent, and loading external scripts based on that consent. You don't describe your services in a config object β you wrap them where they already live, and the banner works the rest out.
- No configuration β there is no services array to keep in sync. Wrap something in
CookieServiceand it registers itself; the settings dialog builds its own per-category, per-service list from whatever is mounted. - Not just scripts β iframes, YouTube embeds, maps, chat widgets, or any React component. If it renders, it can be gated - and if it doesn't render, you can programmatically check its consent state.
- Works with any loader β a plain
<script>,@next/third-parties, or your own wrapper component. - Consent handled automatically β children don't render until the user has consented to that category, or to that one service.
- Google Consent Mode v2 β on by default.
- Multi-tab support β consent is saved in
localStorageand synced between tabs automatically. - Native shadcn/ui β your
Button,Dialog,CheckboxandSwitch, your theme tokens. No bundled CSS. - Compliant defaults β nothing pre-ticked, reject and accept given equal weight, and consent records you can version and expire.
- i18n if you want it β 12 built-in languages, and all the copy is a plain object in your repo to edit.
Live Demo: https://vantezzen.github.io/react-cookie-banner/
import { CookieConsentProvider, CookieBanner, FloatingConsentInfo, CookieService, } from "@/components/cmp"; function App() { return ( // Wrap your app so the consent state is available anywhere <CookieConsentProvider> {/* Display the cookie banner (shows on the first visit) */} <CookieBanner /> {/* Optionally show a floating button so people can change their mind later */} <FloatingConsentInfo /> {/* Add a CookieService for each thing that needs consent. Each one registers itself, so the settings dialog lists them for you. */} <CookieService id="google-analytics" name="Google Analytics" category="analytics" consentMode // Let Google Consent Mode do the gating for this one > <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX" /> </CookieService> <CookieService id="hotjar" name="Hotjar" category="analytics"> <script async src="https://static.hotjar.com/c/hotjar-XXXX.js" /> </CookieService> {/* It doesn't have to be a script β anything that renders works */} <CookieService id="youtube" name="YouTube embeds" category="other" fallback={<p>Accept cookies to watch this video.</p>} > <iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" /> </CookieService> {/* Your app goes here */} </CookieConsentProvider> ); }
Installation
npx shadcn@latest add https://vantezzen.github.io/react-cookie-banner/r/cmp.json
This copies the CMP into components/cmp/ and pulls in the shadcn primitives it
needs (accordion, badge, button, checkbox, dialog, label, switch).
The files are yours β edit them like any other component in your project.
They arrive in two layers:
components/cmp/
base/ headless β state, rules, persistence
types.ts the consent rules and constants
messages.ts texts and the deep merge
use-stored-consent.ts localStorage, cross-tab sync, SSR
consent-mode.ts Google Consent Mode v2
consent-provider.tsx the context and all consent logic
cookie-service.tsx registration and the per-service gate
use-consent-form.ts the settings dialog's state machine
cookie-settings.tsx shadcn markup
cookie-banner.tsx shadcn markup
floating-consent-info.tsx shadcn markup
locales/ optional, one file per language
Nothing in base/ imports from your app, renders styled markup, or knows
shadcn/ui exists β it only depends on React. So if you're restyling, you only
ever open the three files at the top level; if you're changing behaviour, it's
all in base/. Everything is re-exported from @/components/cmp, so your
imports don't care about the split.
Upgrading from a flat install? The shadcn CLI adds files but never removes them. After re-adding you'll have the new
base/folder alongside the old flatcookie-consent-provider.tsx,cookie-service.tsx,messages.ts,types.ts,consent-mode.tsanduse-stored-consent.tsβ delete those six.
Getting started
1. Wrap your app in the CookieConsentProvider
The provider holds the consent state, persists it, and makes it readable anywhere in your tree.
import { CookieConsentProvider } from "@/components/cmp"; function App() { return ( <CookieConsentProvider privacyPolicyUrl="/privacy" // optional, defaults to /privacy > {/* Your app */} </CookieConsentProvider> ); }
In Next.js this goes in your root layout, so it's available on every page.
2. Display the cookie banner
import { CookieBanner } from "@/components/cmp"; <CookieConsentProvider> <CookieBanner /> {/* Your app */} </CookieConsentProvider>;
It shows on the first visit and closes once a choice is made. Two looks are available β a centred modal (the default) or a compact card in the corner which may lead to lower consent rates:
<CookieBanner variant="side" />
3. Add your services
Wrap anything that should only load once the user has consented in a
CookieService. The service registers itself with the provider, so it appears in
the settings dialog under its category, with its own individual toggle, without
you listing it anywhere.
import { CookieService } from "@/components/cmp"; <CookieService id="google-analytics" name="Google Analytics" // Shown to the user in the settings dialog category="analytics" // "essential" | "analytics" | "marketing" | "other" > <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX" /> </CookieService>;
Anything can go inside β a <script>, an <iframe>, a third-party React
component, or nothing at all. Gating services covers each of
those.
4. Let people change their mind
FloatingConsentInfo puts a small button in the bottom-left corner that reopens
the settings at any time.
import { FloatingConsentInfo } from "@/components/cmp"; <CookieConsentProvider> <CookieBanner /> <FloatingConsentInfo /> {/* Your app */} </CookieConsentProvider>;
Or wire up your own trigger β a footer link, for example:
import { useCookieConsent } from "@/components/cmp"; function MyFooter() { const { setOpen } = useCookieConsent(); return ( <footer> <button onClick={() => setOpen(true)}>Cookie settings</button> </footer> ); }
Gating services
There is no built-in list of supported services and no per-service integration.
Any React component, script tag or markup you put inside CookieService is
gated β Google Analytics, Google Ads, Meta Pixel, Hotjar, Intercom, Matomo,
PostHog, YouTube, Google Maps, whatever you have.
Using a script loader
CookieService doesn't care how the script gets onto the page, only whether it
renders. So wrap the loader:
import { GoogleAnalytics } from "@next/third-parties/google"; import { CookieService } from "@/components/cmp"; <CookieService id="google-analytics" name="Google Analytics" category="analytics"> <GoogleAnalytics gaId="G-XXXX" /> </CookieService>;
The same applies to next/script, react-helmet, or your own wrapper.
Several scripts in one service
Put them in the same CookieService β one entry in the dialog, all of them
gated together:
<CookieService id="google-analytics" name="Google Analytics" category="analytics"> <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX" /> <script async src="https://www.google-analytics.com/analytics.js" /> </CookieService>
You can also reuse the same id in several places β they're treated as one
service, and they all load together.
Embeds and other non-scripts
CookieService gates rendering, so an iframe, a map or a chat widget works
exactly like a script tag. Use fallback to explain what's missing instead of
leaving a hole in the page, and setServiceConsent to let people unblock just
that one thing:
import { CookieService, useCookieConsent } from "@/components/cmp"; function Video() { const { setServiceConsent } = useCookieConsent(); return ( <CookieService id="youtube" name="YouTube embeds" category="other" fallback={ <div> <p>This video needs cookies from YouTube.</p> <button onClick={() => setServiceConsent("youtube", true)}> Allow YouTube </button> </div> } > <iframe width="560" height="315" src="https://www.youtube.com/embed/dQw4w9WgXcQ" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" /> </CookieService> ); }
Services without a component
Leave out the children. The service still registers, still appears in the settings dialog, and you can check its state yourself:
<CookieService id="my-script" name="My Script" category="analytics" />; // wherever you need to gate it const { isServiceEnabled } = useCookieConsent(); useEffect(() => { if (isServiceEnabled("my-script")) { // load the script } }, [isServiceEnabled]);
Keeping a service listed
Services register while they're mounted, so a service only appears in the dialog
if it's rendered at the time the dialog opens β and isServiceEnabled returns
false for an id nothing has registered, because there's no category for it to
inherit from.
If a service only exists on some pages, add a childless CookieService somewhere
that's always rendered, so the entry is stable:
// In your layout, next to <CookieBanner /> <CookieService id="youtube" name="YouTube embeds" category="other" />
If something isn't showing up, check in this order: that it's wrapped in a
CookieService at all, that the CookieService is inside the
CookieConsentProvider, and that it's mounted when the dialog opens.
// Won't be gated or listed <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX" /> // Gated and listed <CookieService id="google-analytics" name="Google Analytics" category="analytics"> <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX" /> </CookieService>
Essential services
A service in the essential category is always granted, so it renders its
children regardless of what the user chose β for a session or CSRF cookie you
can't turn off anyway.
<CookieService id="session" name="Session cookie" category="essential" />
Note that the dialog shows a single, locked Essential row rather than listing these individually, so registering one doesn't put its name in front of the user. If you want to show people which essential cookies you set, put them in the category description or your cookie policy:
<CookieConsentProvider messages={{ categories: { essential: { description: "Session and CSRF cookies. Required to log in." }, }, }} >
Reading and setting consent
Everything below comes from useCookieConsent(), which reads the same context
the banner uses.
Reading consent
import { useCookieConsent } from "@/components/cmp"; function Analytics() { const { categories, hasConsented } = useCookieConsent(); if (!categories.analytics) return null; // ... }
hasConsented tells you whether a choice has been made at all, which is
different from everything being denied. For one specific service β taking its
individual override into account β pass its id to isServiceEnabled:
const { isServiceEnabled } = useCookieConsent(); isServiceEnabled("google-analytics");
That reads the category off the registered service, so an override wins if there is one and otherwise the service inherits its category.
Setting consent
There are three levels, from broadest to narrowest:
const { acceptAll, rejectAll, save, setCategoryConsent, setServiceConsent } = useCookieConsent(); // Everything at once acceptAll(); rejectAll(); // A full set of categories, and optionally per-service overrides save({ analytics: true, marketing: false, other: false }); save({ analytics: false, marketing: false, other: false }, { hotjar: true }); // One category, leaving the others alone setCategoryConsent("analytics", true); // One service, leaving everything else alone setServiceConsent("hotjar", false); // ...and back to inheriting its category setServiceConsent("hotjar", null);
A service with no override follows its category. Setting a category β with
setCategoryConsent or with the dialog's master checkbox β clears the overrides
of the services under it, so the category always governs what's beneath it. A
per-service choice only sticks if it's made after the last category change.
Opening the settings dialog
const { isOpen, setOpen } = useCookieConsent(); <button onClick={() => setOpen(true)}>Open</button>; <button onClick={() => setOpen(false)}>Close</button>;
You can also drop CookieSettings in yourself if you want the dialog somewhere
other than the banner β it's a controlled component.
Google Consent Mode
Google Consent Mode is Google's way of sending anonymous signals if a user hasn't consented, rather than blocking the script entirely. It works with Google Analytics, Google Ads, and any other Google tags.
The Cookie Banner sets the Consent Mode v2 information automatically β there is
no component to add. Before anything else runs it puts a
gtag('consent', 'default', β¦) call on window.dataLayer, then sends an
update every time the choice changes:
| Signal | Granted by |
|---|---|
analytics_storage |
analytics consent |
ad_storage, ad_user_data, ad_personalization |
marketing consent |
All four v2 signals are always sent, because Google reads a missing one as denied.
Per-service consent mode
For services that support it, set consentMode on the service so the script
always loads and Google withholds the data instead of the script never running:
<CookieService id="google-analytics" name="Google Analytics" category="analytics" consentMode > <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX" /> </CookieService>
Such a service loads as soon as the consent defaults are on the dataLayer, rather than waiting for consent. Note that this means Google's script runs before the user has chosen β a deliberate trade-off, and one worth reading up on for your jurisdiction.
Turning it off
If you don't use Google tags at all, turn it off and nothing will touch
window.dataLayer:
<CookieConsentProvider consentMode={false}>
If you do that while a service still has the consentMode prop, you'll get a
console warning β drop the prop and the service will be gated normally instead.
Customising
Texts
Every string lives in defaultMessages in base/messages.ts. That file is in
your repo, so just edit it:
// components/cmp/base/messages.ts export const defaultMessages: CmpMessages = { title: "Cookies πͺ", acceptAll: "Sounds good", // ... categories: { analytics: { name: "Usage stats", description: "Tells us which pages people actually read.", }, // ... }, };
This is the way to do it. Changing the source is cleaner than threading a prop through your app, it keeps all your copy in one place, and TypeScript tells you if you miss a key.
The category descriptions in particular are worth rewriting β they're what users read before deciding, and the shipped defaults are deliberately generic placeholders, not a description of what your site does.
There is a messages prop on the provider that deep-merges over the defaults:
<CookieConsentProvider messages={{ acceptAll: "Sounds good" }}>
It exists for the cases where the text can't be static β picking a language at
runtime, mainly, or differing copy on one route. If you're reaching for it to
change wording permanently, edit base/messages.ts instead.
Translations
Ready-made translations ship as a separate, optional registry item:
npx shadcn@latest add https://vantezzen.github.io/react-cookie-banner/r/cmp-locales.json
That drops one file per language into components/cmp/locales/ β ar, de,
es, fr, it, ja, ko, nl, pt, ru, zh:
import { de } from "@/components/cmp/locales/de"; <CookieConsentProvider messages={de}>
There's deliberately no barrel file β import the languages you ship and delete the files you don't. The translations are machine-assisted; corrections welcome, and since they're in your repo you can just fix them in place.
If your site is only ever in one language, don't pass a prop at all β copy that
locale's strings into base/messages.ts and delete the rest. Adding a language
of your own works the same way: a new file next to the others, exporting a
CmpMessagesOverrides object.
// components/cmp/locales/sv.ts import type { CmpMessagesOverrides } from "../base/messages"; export const sv: CmpMessagesOverrides = { title: "KakinstΓ€llningar", // ... };
Appearance
They're your components. The three files at the top level of components/cmp/
are plain shadcn/ui markup β restyle them like anything else. There's no CSS to
override and no theme prop to fight, and you never need to open base/ to change
how something looks.
cookie-settings.tsx is the one worth knowing your way around: CookieSettings
is the dialog shell, SettingsForm is the body and action buttons, and
CategoryItem is a single category row. None of them hold state β the draft, the
per-service inheritance and the "a category governs its services" rule all come
from useConsentForm() in base/:
const form = useConsentForm(); form.categories; // [{ category, checked, toggle, services: [{ id, name, checked, toggle }] }] form.submit; // persist the draft form.acceptAll; form.rejectAll;
That's also the hook to build on if you want a completely different settings UI β a page instead of a dialog, say. You get the state machine and the rules, and write only markup.
Where consent is stored
In localStorage, under the cookie-consent key:
{
"version": 1,
"updatedAt": "2026-07-30T09:12:44.001Z",
"consentVersion": "2026-07",
"categories": { "analytics": true, "marketing": false, "other": false },
"services": { "hotjar": false }
}categories is the category-level choice and services holds only the
individual overrides. Changes are picked up in other tabs through the storage
event, including the key being cleared.
The whole persistence layer is one file, base/use-stored-consent.ts, kept apart
from the consent logic on purpose. To store consent somewhere else β a cookie, so
the server can read it, or your own backend β that's the only file you need to
change. CONSENT_STORAGE_KEY is exported if you just want to clear the record.
The consent lifecycle
Re-asking when things change
Consent covers what you were doing when it was given. If you add a service or
change what a category means, bump consentVersion β anyone who consented under
a different value is treated as not having consented, and is asked again:
<CookieConsentProvider consentVersion="2026-07">
Any string works; a date is easiest to keep track of. Leave the prop off and
stored consent never expires. Every saved record also carries an updatedAt
timestamp, so you can add an age-based rule of your own on top.
When consent is withdrawn
CookieService stops rendering its children β but a script that already ran
keeps running, and the cookies it set stay put. Nothing short of a page load
reliably undoes that. If you'd rather not wait for the user's next navigation:
<CookieConsentProvider reloadOnRevoke>
The page then reloads whenever a save takes away a category or service that was previously granted. Broadening consent, or making a first-ever choice, never reloads. It's off by default because a surprise reload can lose unsaved work β if you have no forms to lose, turning it on makes withdrawal take effect properly.
Server-side rendering
Consent lives in localStorage, which the server can't see, so the server
renders the un-consented tree β gated children show their fallback β and the
real state takes over on hydration. No mismatch warnings and no flash of
wrongly-loaded scripts. The components are client components ("use client"), so
the provider belongs in a layout.
Compliance
Worth being straight about this: a component can give you the mechanics, but whether your site is compliant depends on your processing, your privacy policy and your jurisdiction. This is not legal advice.
What you get out of the box:
- Optional categories start unticked β no pre-checked consent (Planet49, C-673/17).
- Reject is on the first layer, one click, next to accept.
- Consent is granular, per category and per individual service.
- Withdrawing is as easy as giving β
FloatingConsentInfo, or your own trigger. - Nothing gated loads before a choice is made.
- Essential services can't be switched off and are labelled as required.
- Every record is timestamped and schema-versioned, and
consentVersionlets you re-ask.
What is still on you:
- Describe your actual purposes. The default category descriptions are generic placeholders, and
CookieServicecarries only an id, a name and a category. If you need a full cookie declaration β provider, duration, third-country transfers β extend it or link a cookie policy fromprivacyPolicyUrl. - Button prominence. Accept, reject and save are all on the first layer, but accept is styled as the primary button and reject as an outline one. Some supervisory authorities read a more prominent accept as nudging. If yours does, give the reject button the same
variantas accept incookie-settings.tsx. - Expiry. Nothing expires by age. Several authorities expect consent to be re-requested periodically (commonly 6β13 months);
updatedAtis stored so you can implement that, but nothing does it for you. - Proof of consent. Records live in the user's browser and the user can clear them. If you need to demonstrate consent under Art. 7(1), log it server-side too.
- Effective withdrawal. With
reloadOnRevokeoff β the default β a script that already loaded keeps running until the next navigation. - Consent Mode. The
consentModeprop loads Google's script before a choice is made. That is how Google intends Consent Mode to work, and it is also contested; decide deliberately rather than by default. - No IAB TCF. If you work with programmatic advertising partners that require a TCF-registered CMP, this isn't one.
Components
| Component | Purpose |
|---|---|
CookieConsentProvider |
Consent state, persistence and Consent Mode. Wrap your app. |
CookieBanner |
The first-visit banner. dialog or side. |
CookieSettings |
The per-category / per-service dialog (controlled, reusable). |
CookieService |
Gates children behind consent and registers the service. |
FloatingConsentInfo |
Floating button that reopens the settings. |
API
CookieConsentProvider
| Prop | Type | Default | Notes |
|---|---|---|---|
children |
ReactNode |
β | |
privacyPolicyUrl |
string |
"/privacy" |
Linked from the banner and the settings dialog. |
messages |
CmpMessagesOverrides |
English | Any subset of the texts, down to one field of one category. |
consentMode |
boolean |
true |
Google Consent Mode v2. false leaves window.dataLayer alone. |
consentVersion |
string |
β | Bump to re-prompt everyone. |
reloadOnRevoke |
boolean |
false |
Reload the page when consent is withdrawn. |
CookieBanner
| Prop | Type | Default |
|---|---|---|
variant |
"dialog" | "side" |
"dialog" |
CookieSettings
| Prop | Type | Default | Notes |
|---|---|---|---|
open |
boolean |
β | Controlled. |
onOpenChange |
(open: boolean) => void |
β | |
dismissible |
boolean |
true |
false forces a choice β no close, no esc. |
CookieService
| Prop | Type | Default | Notes |
|---|---|---|---|
id |
string |
β | Stable key for the per-service override. |
name |
string |
β | Shown to the user in the settings dialog. |
category |
"essential" | "analytics" | "marketing" | "other" |
β | essential is always granted. |
children |
ReactNode |
β | Optional β omit to register without gating. |
fallback |
ReactNode |
null |
Rendered while consent is missing. |
consentMode |
boolean |
false |
Load always and let Google Consent Mode gate data. |
useCookieConsent()
| Returns | Type | Notes |
|---|---|---|
categories |
{ analytics: boolean; marketing: boolean; other: boolean } |
The category-level choice. |
serviceConsents |
Record<string, boolean> |
Individual overrides only. |
hasConsented |
boolean |
Whether a choice has been made at all. |
save |
(categories, services?) => void |
Persists both levels and closes the banner. |
acceptAll |
() => void |
|
rejectAll |
() => void |
|
setCategoryConsent |
(category, granted) => void |
One category; clears the overrides under it. |
setServiceConsent |
(id, granted | null) => void |
One service; null restores inheritance. |
services |
CookieServiceDetails[] |
Everything currently registered. |
isServiceEnabled |
(id: string) => boolean |
Override if set, otherwise the registered service's category. false if the id isn't registered. |
isOpen / setOpen |
boolean / (open: boolean) => void |
Banner visibility. |
isConsentModeActive |
boolean |
Whether Consent Mode has sent its defaults. |
messages |
CmpMessages |
The resolved texts. |
privacyPolicyUrl |
string |
registerService and unregisterService are also on the context, but
CookieService calls them for you.
Development
This repo is both the registry source and the demo site.
bun install bun run dev # demo at localhost:5173 bun run test # vitest bun run registry:build # regenerate public/r/*.json bun run build # build registry + demo into dist/ (deployed to Pages)
The CMP source lives in src/registry/cmp/; registry.json maps it into the
shadcn registry. The GitHub Action in .github/workflows/deploy.yml builds and
publishes both the demo and r/cmp.json to GitHub Pages on every push to main.
License
MIT
