Cookie Consent Banner in React: GDPR, Animate In/Out, Store Prefs
Build a GDPR-compliant cookie consent banner in React with smooth slide animations, localStorage persistence, and granular category toggles. No bloated libraries.
Why You Still Can't Ignore Cookie Consent in 2026
GDPR fines crossed β¬4.5 billion in cumulative penalties by the end of 2025. That number should get your attention. If you're shipping a React app that touches EU users - even tangentially - slapping a one-line localStorage flag on it and calling it done is not a legal strategy, it's a gamble.
The regulation itself hasn't changed that much since 2018, but enforcement has gotten teeth. Data protection authorities in France (CNIL), Ireland (DPC), and Germany have all issued seven-figure fines specifically for non-compliant consent implementations - missing granularity, pre-ticked boxes, or consent stored without proof. Your banner needs to do more than just appear.
In practice, a legally sound cookie banner in React needs three things: it must show before any non-essential scripts load, it must let users accept or reject by category (not just a blanket yes/no), and it must remember that choice persistently. Everything else - the animation, the design system integration, the TypeScript types - is gravy. Important gravy, but gravy.
Worth noting: this guide builds everything from scratch. No react-cookie-consent package, no CookieBot embed. You'll understand every line, which matters when a lawyer asks you to prove your implementation is compliant.
The Data Model: Categories, Consent State, and Persistence
Start with the shape of your data. Cookie consent isn't binary anymore - you need categories. The standard split used by most GDPR implementations is: necessary (always on, no toggle), analytics, marketing, and preferences. Some add functional as a fifth, but four covers 90% of real apps.
// types/consent.ts
export type ConsentCategory = 'necessary' | 'analytics' | 'marketing' | 'preferences';
export interface ConsentState {
decided: boolean; // has the user made any choice?
timestamp: number; // unix ms - you need this for audit trails
necessary: true; // always true, never toggled
analytics: boolean;
marketing: boolean;
preferences: boolean;
}
export const DEFAULT_CONSENT: ConsentState = {
decided: false,
timestamp: 0,
necessary: true,
analytics: false,
marketing: false,
preferences: false,
};The decided flag is the key one. When decided is false, you haven't loaded any non-essential scripts yet. When it flips to true, you fire the appropriate initialization calls - GA4, Meta Pixel, whatever - based on which categories are enabled. The timestamp field matters legally: it proves *when* consent was granted, which DPAs sometimes ask for.
Persistence goes in localStorage. Don't use cookies to store cookie consent - that's a recursive irony that also causes cross-subdomain headaches. One caveat: localStorage is origin-scoped, so if you run your app across multiple subdomains you'll need a shared consent domain or a cookie fallback. For most single-origin SPAs and Next.js apps, localStorage is exactly right.
// hooks/useConsent.ts
import { useState, useEffect } from 'react';
import { ConsentState, DEFAULT_CONSENT } from '../types/consent';
const STORAGE_KEY = 'empire_consent_v1';
export function useConsent() {
const [consent, setConsent] = useState<ConsentState>(DEFAULT_CONSENT);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
setConsent(JSON.parse(stored));
}
} catch {
// localStorage blocked (private mode, some browsers) - degrade gracefully
} finally {
setLoaded(true);
}
}, []);
const save = (next: ConsentState) => {
setConsent(next);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch { /* silent */ }
};
return { consent, save, loaded };
}Building the Banner Component
The banner itself is a fixed-position panel at the bottom of the viewport. Honestly, the visual design is where most implementations phone it in - a grey box with two buttons that feels like an afterthought. You can do better with 20 extra lines of Tailwind, and it'll actually improve your accept rate.
// components/CookieBanner.tsx
import { useState } from 'react';
import { ConsentState } from '../types/consent';
interface Props {
onSave: (state: ConsentState) => void;
initial: ConsentState;
}
export function CookieBanner({ onSave, initial }: Props) {
const [expanded, setExpanded] = useState(false);
const [prefs, setPrefs] = useState({
analytics: initial.analytics,
marketing: initial.marketing,
preferences: initial.preferences,
});
const toggle = (key: keyof typeof prefs) =>
setPrefs(p => ({ ...p, [key]: !p[key] }));
const acceptAll = () =>
onSave({ decided: true, timestamp: Date.now(), necessary: true,
analytics: true, marketing: true, preferences: true });
const rejectAll = () =>
onSave({ decided: true, timestamp: Date.now(), necessary: true,
analytics: false, marketing: false, preferences: false });
const saveCustom = () =>
onSave({ decided: true, timestamp: Date.now(), necessary: true, ...prefs });
return (
<div className="fixed bottom-0 inset-x-0 z-50 p-4 md:p-6">
<div className="max-w-2xl mx-auto bg-white/10 backdrop-blur-md border border-white/20
rounded-2xl shadow-2xl p-6 text-white">
<p className="text-sm leading-relaxed mb-4">
We use cookies to keep the site working (necessary) and, with your
permission, to understand how you use it (analytics) and show relevant
content (marketing).
</p>
{expanded && (
<div className="mb-4 space-y-3">
{(['analytics', 'marketing', 'preferences'] as const).map(cat => (
<label key={cat} className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={prefs[cat]}
onChange={() => toggle(cat)}
className="w-4 h-4 accent-violet-400"
/>
<span className="capitalize text-sm">{cat}</span>
</label>
))}
</div>
)}
<div className="flex flex-wrap gap-2">
<button onClick={acceptAll}
className="px-4 py-2 bg-violet-500 hover:bg-violet-600 rounded-xl text-sm font-medium transition-colors">
Accept all
</button>
<button onClick={rejectAll}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-xl text-sm font-medium transition-colors">
Reject all
</button>
<button onClick={() => setExpanded(e => !e)}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-xl text-sm font-medium transition-colors">
{expanded ? 'Hide options' : 'Customise'}
</button>
{expanded && (
<button onClick={saveCustom}
className="px-4 py-2 bg-emerald-500 hover:bg-emerald-600 rounded-xl text-sm font-medium transition-colors">
Save preferences
</button>
)}
</div>
</div>
</div>
);
}The glassmorphism styling here (bg-white/10 backdrop-blur-md border border-white/20) ties the banner into the same design language as the rest of your app if you're using Empire UI's glassmorphism components. It also means the banner doesn't feel like a foreign object dropped on top of your UI - it feels like it belongs.
One more thing - the expanded state for the customise panel means you avoid a full preferences modal, which is a separate route some implementations add. Keeping it inline means no navigation, no scroll position management, and a much simpler component tree. That said, if your app's privacy setup is complex (10+ cookie categories, detailed descriptions per cookie), a dedicated modal or page is the right call.
Animate In and Out with Framer Motion
A banner that snaps into existence at the bottom of the screen is jarring. A banner that slides up smoothly from below - and slides back down when dismissed - feels intentional. Framer Motion is already a near-universal dependency in React apps built after 2023, so adding the animation is just a few lines.
// components/CookieBanner.tsx (with animation)
import { motion, AnimatePresence } from 'framer-motion';
interface AnimatedBannerProps {
visible: boolean;
onSave: (state: ConsentState) => void;
initial: ConsentState;
}
export function AnimatedCookieBanner({ visible, onSave, initial }: AnimatedBannerProps) {
return (
<AnimatePresence>
{visible && (
<motion.div
key="cookie-banner"
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '100%', opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="fixed bottom-0 inset-x-0 z-50 p-4 md:p-6"
>
<CookieBanner onSave={onSave} initial={initial} />
</motion.div>
)}
</AnimatePresence>
);
}The spring transition with stiffness: 300 and damping: 30 gives a snappy entry that doesn't overshoot. The exit mirrors the entry - slides back down, fades out. AnimatePresence handles the unmounting: without it, the exit animation would never run because React would remove the element from the DOM immediately on visible becoming false.
Quick aside: if you're not using Framer Motion, you can get 80% of the way there with pure CSS and a data-state attribute. Set data-state="visible" and toggle it with JS, then write @keyframes slideUp targeting [data-state='visible']. The exit animation is harder without AnimatePresence, though - you'd need to delay the DOM removal manually with setTimeout.
If you want to match the animation style of the rest of your interactive components - tabs, modals, toasts - check how animated tabs in React handles coordinated entrance/exit patterns. Same principles apply here.
Wiring It Into Your App and Firing Scripts Conditionally
The hook and banner component are ready. Now you need to connect them at the app root and make your analytics/marketing scripts actually respond to the consent state. This is where most tutorials stop too early.
// app/layout.tsx (Next.js 14+ App Router)
import { ConsentProvider } from '@/components/ConsentProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ConsentProvider>
{children}
</ConsentProvider>
</body>
</html>
);
}
// components/ConsentProvider.tsx
'use client';
import { createContext, useContext, useEffect } from 'react';
import { useConsent } from '@/hooks/useConsent';
import { AnimatedCookieBanner } from './CookieBanner';
import { ConsentState } from '@/types/consent';
const ConsentCtx = createContext<ConsentState | null>(null);
export const useConsentCtx = () => useContext(ConsentCtx)!;
export function ConsentProvider({ children }: { children: React.ReactNode }) {
const { consent, save, loaded } = useConsent();
// Fire analytics when consent is granted
useEffect(() => {
if (!consent.decided) return;
if (consent.analytics) {
// GA4 init - safe to call multiple times, gtag is idempotent
window.gtag?.('consent', 'update', {
analytics_storage: 'granted',
});
}
if (consent.marketing) {
window.gtag?.('consent', 'update', {
ad_storage: 'granted',
ad_user_data: 'granted',
});
}
}, [consent.decided, consent.analytics, consent.marketing]);
return (
<ConsentCtx.Provider value={consent}>
{children}
{loaded && (
<AnimatedCookieBanner
visible={!consent.decided}
onSave={save}
initial={consent}
/>
)}
</ConsentCtx.Provider>
);
}The loaded guard is non-negotiable. Without it, the banner flashes on screen for a split second during hydration even when the user already consented - because localStorage isn't available during server-side rendering. Waiting for loaded (which only flips after the useEffect runs on the client) prevents the flash.
Look, the GA4 Consent Mode v2 integration shown above is the correct way to handle Google Analytics in a GDPR context. You send a consent update signal rather than conditionally injecting the gtag.js script. Google loads the script either way (with a default-denied state set in your <head>) but only processes data once you send granted. This also means you don't lose attribution data for users who do consent - the pending hits get replayed.
For non-Google scripts (Meta Pixel, Hotjar, Intercom), you'd conditionally inject a <script> tag using a useEffect that depends on consent.marketing. Don't put these in <head> unconditionally - that's what gets you the CNIL fine.
Resetting Consent and the 'Manage Cookies' Footer Link
GDPR requires that users can withdraw consent as easily as they granted it. That means a persistent way to re-open the preferences panel - usually a 'Manage cookies' link in the footer. It's not optional.
// components/ManageCookiesLink.tsx
'use client';
import { useConsentCtx } from './ConsentProvider';
// We need a way to re-open the banner. Expose a reset function from the hook.
// Add this to useConsent: const reset = () => save(DEFAULT_CONSENT);
export function ManageCookiesLink() {
const { reset } = useConsentCtx(); // extend ConsentCtx to include reset
return (
<button
onClick={reset}
className="text-sm text-gray-400 hover:text-white underline transition-colors"
>
Manage cookies
</button>
);
}Calling reset sets decided back to false, which makes visible flip back to true in the banner, which triggers the slide-up animation again. The user sees the full banner with their previous choices pre-populated (since initial is passed from consent). They can accept, reject, or fine-tune and save again.
One practical note: when a user withdraws consent (switches analytics from true to false), you should send the corresponding denied update to GA4 and call any cleanup functions the third-party scripts expose. You can't retroactively delete data already sent, but you should stop sending new data immediately.
That's pretty much the full picture. The implementation above fits in under 200 lines across four files, handles SSR correctly, passes the Customise flow GDPR requires, and produces a consent record with a timestamp you can export if you ever need to prove compliance. If you want the banner's visual design to match the aesthetic from the Empire UI component library, the glassmorphism panel styling slots in directly.
Testing and Common Pitfalls
How do you actually test this? Three ways. First, open DevTools β Application β Local Storage, delete the empire_consent_v1 key, and reload. The banner should slide up. Make a choice, reload again - the banner should stay hidden and the decided flag should be true in storage.
Second, test with JavaScript disabled or in a privacy-focused browser that blocks localStorage. The banner should still render (it defaults to DEFAULT_CONSENT which has decided: false), and your non-essential scripts should not load. This is the SSR hydration edge case - catch it early.
Third, run a network trace. After accepting all, GA4's collect endpoint should start firing. After rejecting all and reloading, it shouldn't. If you see GA4 or Meta Pixel requests firing before consent, something is loading those scripts unconditionally in your _document.tsx or a third-party tag manager.
Common pitfalls in order of frequency: injecting analytics scripts in <head> without Consent Mode, forgetting the loaded guard (banner flash), using sessionStorage instead of localStorage (consent resets every tab), and not providing a way to re-open preferences. Avoid those four and you're in a solid legal position. For the actual banner interaction patterns - focus trapping, keyboard dismissal, ARIA roles - the same accessibility principles from React toast notifications apply directly here.
FAQ
No - necessary cookies (session management, auth tokens, CSRF protection) don't require consent under GDPR Article 6(1)(b) or the ePrivacy Directive. You still need to disclose them in your privacy policy, but no banner is required.
Yes, for single-origin apps. localStorage is technically not a cookie, but storing consent records there is widely accepted by DPAs. Include a timestamp in the stored object so you have an audit trail showing when consent was given.
No - GDPR requires opt-in, not opt-out, for non-necessary cookies. Pre-checked boxes or default-on states for analytics or marketing cookies are explicitly prohibited and have resulted in significant fines from the CNIL and other DPAs.
GDPR doesn't set a fixed expiry, but most DPA guidance suggests re-asking every 12 months is good practice. Store the consent timestamp and compare it on app load - if it's older than 365 days, reset decided to false and show the banner again.