Blog//7 min read
Cookie consent banner in React with category toggles
Build a React cookie consent banner with analytics and marketing toggles, store the choice, block scripts until accepted and let visitors reopen settings.
MSMarco Schulz, founder of Facade UI
Most cookie banners are a dark gray bar with one button that says "OK" and does nothing. They annoy visitors, they do not actually gate anything, and they would not hold up if anyone asked what the click meant. I wanted one component I could drop on every client site that does the three things a consent banner is for: ask a clear question, remember the answer, and keep the analytics scripts off until the answer is yes. This is how the Cookie consent card works and how to wire it into a Next.js site.
I am not a lawyer and this is not legal advice. The technical patterns below are what I implement; which categories you need and how you word them is a question for whoever advises you on privacy for your market.
Consent categories
The card has three categories, and the shape is fixed on purpose:
interface ConsentState {
necessary: true;
analytics: boolean;
marketing: boolean;
}necessary is typed as the literal true. It cannot be switched off, because the cookies in that bucket are the ones the site needs to function: the session, the cart, the consent choice itself. Rendering it as a toggle that is always on and disabled tells the visitor it exists without pretending they have a choice about it.
analytics covers measurement: Vercel Analytics, GA4 and the like. marketing covers anything that follows the visitor elsewhere: Meta pixel, Google Ads tags, remarketing. For a local business site these two buckets cover everything I have needed. If a client wants a "functional" category, the source is in your project and a fourth key is a few lines.
The toggles are real role="switch" elements with aria-checked, a label and a description, so a screen reader announces "Analytics, switch, off" and the explanatory sentence under it.
The three actions
The card shows two buttons at first: Reject and Accept all. A "Customize preferences" disclosure expands the category list, and while it is open the pair becomes Save choices and Accept all, so the toggles the visitor just set are what gets saved. Each path calls onChange with the resulting state and a string that tells you which button was pressed:
type ConsentAction = "accept-all" | "reject" | "save";Giving Reject the same visual weight as Accept matters. A gray "Reject" text link next to a big brand colored "Accept" button is the design European regulators have pushed back on for years. The Facade card renders both as buttons of the same size; only the primary gets the brand fill.
Storing the choice
When the visitor decides, the component writes the ConsentState as JSON to localStorage under storageKey, which defaults to "facade-cookie-consent". On the next page load it checks for that key before showing anything. If a choice exists, the card stays hidden. If not, it appears after delay milliseconds, 600 by default, so the page settles before the card slides in.
If your server decides at render time whether to inject analytics scripts, mirror the choice into a cookie from onChange; localStorage stays the client side cache.
"use client";
import { CookieConsent, type ConsentState, type ConsentAction } from "@/components/facade/cookie-consent";
import { applyConsent } from "@/lib/consent";
export function ConsentCard() {
return (
<CookieConsent
title="We use a few cookies"
description="Necessary cookies keep the site working. Analytics tell us which pages help. Marketing cookies are off unless you turn them on."
policyHref="/legal/privacy"
storageKey="smile-dental-consent"
delay={800}
onChange={(consent: ConsentState, action: ConsentAction) => {
applyConsent(consent);
if (action === "reject") console.log("optional cookies rejected");
}}
/>
);
}Pass storageKey={null} to disable persistence entirely, which is handy in previews and tests where you want the card to show every time.
Blocking analytics until accepted
This is the part most banners skip. A banner that shows while GA4 is already loaded is a notice, not consent. The pattern has three parts.
First, a tiny module that reads the stored choice and exposes it:
// lib/consent.ts
export type Consent = { necessary: true; analytics: boolean; marketing: boolean };
export function readConsent(key = "smile-dental-consent"): Consent | null {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as Consent) : null;
} catch {
return null;
}
}
export function applyConsent(c: Consent) {
window.dispatchEvent(new CustomEvent("consentchange", { detail: c }));
}Second, an analytics loader component that renders the script only when analytics is true. It reads the stored consent on mount and listens for consentchange, so the moment the visitor clicks Accept, the script loads without a reload:
"use client";
import { useEffect, useState } from "react";
import { Analytics } from "@vercel/analytics/next";
import { readConsent, type Consent } from "@/lib/consent";
export function GatedAnalytics() {
const [consent, setConsent] = useState<Consent | null>(null);
useEffect(() => {
setConsent(readConsent());
const onChange = (e: Event) => setConsent((e as CustomEvent<Consent>).detail);
window.addEventListener("consentchange", onChange);
return () => window.removeEventListener("consentchange", onChange);
}, []);
if (!consent?.analytics) return null;
return <Analytics />;
}Third, the same gate for marketing tags, keyed on consent.marketing. With Google Tag Manager, use Consent Mode: default to denied before the container loads and update from onChange.
The card collects the decision; your code enforces it. Enforcement is where the exposure lives, and it belongs in code you read, not in a library.
Reopening settings
A visitor who clicked Reject in March has to be able to change their mind in June, and they will look for the link in the footer. The card supports this through the open prop. When open is a boolean, the component becomes controlled: it shows when open is true and ignores the stored choice.
const [showConsent, setShowConsent] = useState<boolean | undefined>(undefined);
<button type="button" onClick={() => setShowConsent(true)}>Cookie settings</button>
<CookieConsent
open={showConsent}
onChange={(c) => {
applyConsent(c);
setShowConsent(undefined); // back to uncontrolled, which hides it after a choice
}}
/>Leave open as undefined for the normal first visit flow and set it to true from the footer link. After the visitor saves, flip it back to undefined so the stored choice takes over again.
The card is fixed in a corner, is a role="dialog" with aria-modal="false", and does not trap focus or block the page. A modal that blocks everything until you click is the pattern that makes people click Accept without reading.
GDPR and CCPA, briefly
Two frameworks shape most requests, and they ask for different behaviors.
Under the GDPR and ePrivacy rules in the EU and UK, non essential cookies need consent before they are set: analytics and marketing default to off, the card asks first, the gate keeps scripts unloaded until the answer is yes.
Under the CCPA and CPRA in California, the model is closer to opt out: you can set cookies but must honor a "Do Not Sell or Share" request and the Global Privacy Control signal. If a California visitor's browser sends navigator.globalPrivacyControl === true, treat marketing as rejected regardless of what the stored choice says. Add that check to readConsent and you cover it in one place.
Which regime applies depends on where the visitors are and what the site does; take that question to a privacy professional. The component makes either answer implementable without rewriting the banner.
Installing
The Cookie consent card is free and MIT licensed:
npx shadcn@latest add https://facadeui.com/r/cookie-consent.jsonRender it once in the root layout next to your gated analytics component. On a dental site, where trust is the whole pitch, the card and the gated loader go in from the first deploy; the dentist page shows the rest of that page order.
FAQ
Do I need a cookie banner if I only use Vercel Analytics?
Vercel Analytics does not set cookies in its default configuration, so many sites skip the banner for it. Add any tag that sets a cookie or fingerprints the visitor and the answer changes. Check what each script actually sets before deciding.
Should Reject be hidden behind Customize?
No. Hiding the reject option behind an extra click is the pattern European regulators have objected to. Keep Accept all and Reject side by side on the first view, and put the granular toggles behind Customize.
How long should the choice be remembered?
localStorage persists until cleared, so the choice lasts as long as the visitor keeps their browser data. If you mirror it to a cookie, six months is a common max-age, after which the card asks again.