GitHub - vantezzen/react-cookie-banner: πŸš€ A simple, full-service cookie banner solution for React

GitHub

18 min read Original article β†—

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 CookieService and 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 localStorage and synced between tabs automatically.
  • Native shadcn/ui β€” your Button, Dialog, Checkbox and Switch, 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.

Cookie Banner

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 flat cookie-consent-provider.tsx, cookie-service.tsx, messages.ts, types.ts, consent-mode.ts and use-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 consentVersion lets you re-ask.

What is still on you:

  • Describe your actual purposes. The default category descriptions are generic placeholders, and CookieService carries 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 from privacyPolicyUrl.
  • 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 variant as accept in cookie-settings.tsx.
  • Expiry. Nothing expires by age. Several authorities expect consent to be re-requested periodically (commonly 6–13 months); updatedAt is 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 reloadOnRevoke off β€” the default β€” a script that already loaded keeps running until the next navigation.
  • Consent Mode. The consentMode prop 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