Cookie Modal
Original · freecookie preferences modal
byWeb InnoventixReact + Tailwind
modalcookiemodals
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/modal-cookie.jsonmodal-cookie.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type CategoryId = "necessary" | "functional" | "analytics" | "marketing";
type Preferences = Record<CategoryId, boolean>;
interface Category {
id: CategoryId;
name: string;
required?: boolean;
summary: string;
providers: string[];
}
const CATEGORIES: Category[] = [
{
id: "necessary",
name: "Strictly necessary",
required: true,
summary:
"Keep you signed in, balance server load, and remember these very cookie choices. The site breaks without them, so they stay on.",
providers: ["Session", "CSRF token", "Load balancer"],
},
{
id: "functional",
name: "Functional",
summary:
"Remember the small stuff — language, region, dark mode, and which panels you collapsed — so the site feels the way you left it.",
providers: ["Preferences", "Theme", "Locale"],
},
{
id: "analytics",
name: "Analytics",
summary:
"Privacy-friendly, aggregate stats via Plausible that show which articles get read and where readers lose interest. No cross-site tracking.",
providers: ["Plausible", "Page timing"],
},
{
id: "marketing",
name: "Marketing",
summary:
"Measure how our campaigns perform and tailor what you see off-site. We never sell your data to third parties.",
providers: ["Conversion pixel", "Retargeting"],
},
];
const ALL_ON: Preferences = { necessary: true, functional: true, analytics: true, marketing: true };
const ALL_OFF: Preferences = { necessary: true, functional: false, analytics: false, marketing: false };
function getFocusable(root: HTMLElement): HTMLElement[] {
const selector =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
return Array.from(root.querySelectorAll<HTMLElement>(selector)).filter(
(el) => el.getAttribute("aria-hidden") !== "true" && el.offsetParent !== null,
);
}
function CookieIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 3a9 9 0 1 0 9 9 3 3 0 0 1-3-3 3 3 0 0 1-3-3 3 3 0 0 1-3-3Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<circle cx="9" cy="11.5" r="1.1" fill="currentColor" />
<circle cx="13" cy="15" r="1.1" fill="currentColor" />
<circle cx="15.5" cy="11" r="1" fill="currentColor" />
<circle cx="8.5" cy="15.5" r="0.9" fill="currentColor" />
</svg>
);
}
function CloseIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
interface ToggleSwitchProps {
checked: boolean;
onChange: (next: boolean) => void;
disabled?: boolean;
label: string;
describedBy?: string;
}
function ToggleSwitch({ checked, onChange, disabled = false, label, describedBy }: ToggleSwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
aria-describedby={describedBy}
disabled={disabled}
onClick={() => onChange(!checked)}
className={[
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-200 motion-reduce:transition-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2",
"focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
checked ? "bg-emerald-500" : "bg-slate-300 dark:bg-slate-600",
disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer",
].join(" ")}
>
<span
className={[
"inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform duration-200 motion-reduce:transition-none",
checked ? "translate-x-5" : "translate-x-0.5",
].join(" ")}
/>
</button>
);
}
export default function CookiePreferencesModal() {
const reduce = useReducedMotion();
const [open, setOpen] = useState(false);
const [saved, setSaved] = useState<Preferences>(ALL_OFF);
const [draft, setDraft] = useState<Preferences>(ALL_OFF);
const [status, setStatus] = useState<string>("");
const panelRef = useRef<HTMLDivElement | null>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
const titleId = useId();
const introId = useId();
const activeCount = Object.values(saved).filter(Boolean).length;
function openModal() {
setDraft(saved);
setOpen(true);
}
function commit(next: Preferences) {
const settled: Preferences = { ...next, necessary: true };
setSaved(settled);
const count = Object.values(settled).filter(Boolean).length;
setStatus(`Preferences saved — ${count} of ${CATEGORIES.length} categories active.`);
setOpen(false);
}
function setDraftFor(id: CategoryId, value: boolean) {
setDraft((prev) => ({ ...prev, [id]: value, necessary: true }));
}
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement | null;
const panel = panelRef.current;
if (!panel) return;
const focusables = getFocusable(panel);
(focusables[0] ?? panel).focus();
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
return;
}
if (event.key !== "Tab" || !panel) return;
const items = getFocusable(panel);
if (items.length === 0) {
event.preventDefault();
panel.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (event.shiftKey) {
if (active === first || !panel.contains(active)) {
event.preventDefault();
last.focus();
}
} else if (active === last || !panel.contains(active)) {
event.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", onKeyDown);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = previousOverflow;
previouslyFocused.current?.focus?.();
};
}, [open]);
const overlayVariants = { hidden: { opacity: 0 }, visible: { opacity: 1 }, exit: { opacity: 0 } };
const panelVariants = reduce
? { hidden: { opacity: 0 }, visible: { opacity: 1 }, exit: { opacity: 0 } }
: {
hidden: { opacity: 0, y: 24, scale: 0.97 },
visible: { opacity: 1, y: 0, scale: 1 },
exit: { opacity: 0, y: 12, scale: 0.98 },
};
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 sm:px-6 sm:py-28 dark:bg-slate-950">
<style>{`
@keyframes cookieprefs-pulse { 0%, 100% { opacity: .35; transform: scale(1); } 50% { opacity: 1; transform: scale(1.6); } }
@keyframes cookieprefs-sheen { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.cookieprefs-pulse { animation: cookieprefs-pulse 2.4s ease-in-out infinite; }
.cookieprefs-sheen { animation: cookieprefs-sheen 6s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.cookieprefs-pulse, .cookieprefs-sheen { animation: none !important; }
}
`}</style>
{/* Ambient background */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
<div className="absolute -bottom-32 right-0 h-80 w-80 rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-700/20" />
<div className="absolute inset-0 opacity-[0.35] [background-image:linear-gradient(to_right,rgba(100,116,139,0.12)_1px,transparent_1px),linear-gradient(to_bottom,rgba(100,116,139,0.12)_1px,transparent_1px)] [background-size:38px_38px] [mask-image:radial-gradient(ellipse_at_center,black,transparent_75%)]" />
</div>
<div className="relative mx-auto max-w-3xl">
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white/80 shadow-xl shadow-slate-900/5 backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-black/30">
<div className="p-8 sm:p-10">
<span className="cookieprefs-sheen inline-flex items-center gap-2 bg-gradient-to-r from-indigo-500 via-violet-400 to-indigo-500 bg-[length:200%_100%] bg-clip-text text-xs font-semibold uppercase tracking-[0.2em] text-transparent">
<CookieIcon className="h-4 w-4 text-indigo-500 dark:text-indigo-400" />
Privacy center
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
You decide what we remember.
</h2>
<p className="mt-4 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-300">
Innoventix uses a small set of cookies to run reliably, understand what's useful, and improve. Necessary
cookies are always on. Everything else is your call — change it any time.
</p>
<div className="mt-6 flex flex-wrap items-center gap-2">
{CATEGORIES.map((cat) => {
const on = saved[cat.id];
return (
<span
key={cat.id}
className={[
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium",
on
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/50 dark:text-slate-400",
].join(" ")}
>
<span
className={[
"h-1.5 w-1.5 rounded-full",
on ? "cookieprefs-pulse bg-emerald-500" : "bg-slate-400 dark:bg-slate-500",
].join(" ")}
/>
{cat.name}
</span>
);
})}
</div>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<button
type="button"
onClick={openModal}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-slate-900 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
>
<CookieIcon className="h-4 w-4" />
Manage preferences
</button>
<button
type="button"
onClick={() => commit(ALL_ON)}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
>
Accept all
</button>
</div>
<p role="status" aria-live="polite" className="mt-5 min-h-[1.25rem] text-sm text-slate-500 dark:text-slate-400">
{status || `${activeCount} of ${CATEGORIES.length} categories active · Last choice applied on this device.`}
</p>
</div>
</div>
</div>
<AnimatePresence>
{open && (
<motion.div
key="cookie-overlay"
className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/60 p-4 backdrop-blur-sm sm:items-center"
variants={overlayVariants}
initial="hidden"
animate="visible"
exit="exit"
transition={{ duration: 0.18 }}
onClick={(event) => {
if (event.target === event.currentTarget) setOpen(false);
}}
>
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={introId}
tabIndex={-1}
variants={panelVariants}
initial="hidden"
animate="visible"
exit="exit"
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl focus:outline-none dark:border-slate-800 dark:bg-slate-900"
>
{/* Header */}
<div className="flex items-start gap-4 border-b border-slate-200 p-6 dark:border-slate-800">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400">
<CookieIcon className="h-6 w-6" />
</span>
<div className="min-w-0 flex-1">
<h3 id={titleId} className="text-lg font-semibold text-slate-900 dark:text-white">
Cookie preferences
</h3>
<p id={introId} className="mt-1 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
Toggle each category on or off. Changes apply when you save.
</p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close cookie preferences"
className="-mr-1 -mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
>
<CloseIcon className="h-5 w-5" />
</button>
</div>
{/* Body */}
<div className="flex-1 space-y-3 overflow-y-auto p-6">
{CATEGORIES.map((cat) => {
const descId = `${titleId}-${cat.id}`;
const checked = cat.required ? true : draft[cat.id];
return (
<div
key={cat.id}
className="rounded-xl border border-slate-200 bg-slate-50/60 p-4 dark:border-slate-800 dark:bg-slate-800/40"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-slate-900 dark:text-white">{cat.name}</h4>
{cat.required && (
<span className="inline-flex items-center gap-1 rounded-full bg-slate-200 px-2 py-0.5 text-[11px] font-medium text-slate-600 dark:bg-slate-700 dark:text-slate-300">
<CheckIcon className="h-3 w-3" />
Always on
</span>
)}
</div>
<p id={descId} className="mt-1.5 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{cat.summary}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{cat.providers.map((provider) => (
<span
key={provider}
className="rounded-md border border-slate-200 bg-white px-2 py-0.5 text-[11px] font-medium text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400"
>
{provider}
</span>
))}
</div>
</div>
<ToggleSwitch
checked={checked}
disabled={cat.required}
onChange={(next) => setDraftFor(cat.id, next)}
label={`${cat.name} cookies`}
describedBy={descId}
/>
</div>
</div>
);
})}
</div>
{/* Footer */}
<div className="flex flex-col gap-3 border-t border-slate-200 p-6 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800">
<button
type="button"
onClick={() => commit(ALL_OFF)}
className="inline-flex items-center justify-center rounded-xl px-4 py-2.5 text-sm font-semibold text-rose-600 transition-colors hover:bg-rose-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-rose-400 dark:hover:bg-rose-950/40 dark:focus-visible:ring-offset-slate-900"
>
Reject all
</button>
<div className="flex flex-col gap-3 sm:flex-row">
<button
type="button"
onClick={() => commit(draft)}
className="inline-flex items-center justify-center rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
>
Save preferences
</button>
<button
type="button"
onClick={() => commit(ALL_ON)}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
>
<CheckIcon className="h-4 w-4" />
Accept all
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Modal
Originalbasic centred modal with overlay

Center Modal
Originalcentred modal with icon and actions

Form Modal
Originalmodal containing a form

Confirm Modal
Originalconfirm/destructive dialog

Alert Modal
Originalalert dialog with single action

Drawer Bottom Modal
Originalbottom sheet drawer

Side Drawer Modal
Originalright side drawer modal

Fullscreen Modal
Originalfullscreen modal

Image Lightbox Modal
Originalimage lightbox modal with nav

Video Modal
Originalvideo player modal

Scroll Modal
Originalmodal with long scrollable body

Steps Modal
Originalmulti-step modal flow

