Consent bar that slides up once on the drawer ease, offers equal-weight accept/necessary-only choices, and folds a per-category switch list open — opt-in by default, never re-nags.
npx shadcn@latest add @paragon/cookie-consentAlso installs: button, switch
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown, Cookie } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
import { Switch } from "@/registry/paragon/ui/switch";
export interface ConsentCategory {
id: string;
label: string;
description?: string;
/** Always on and not toggleable (e.g. strictly necessary cookies). */
required?: boolean;
}
export interface CookieConsentProps {
title?: string;
description?: React.ReactNode;
categories?: ConsentCategory[];
/** ms after mount before the bar slides up. */
delay?: number;
/** Open the granular preferences on first render. */
defaultExpanded?: boolean;
onAcceptAll?: (prefs: Record<string, boolean>) => void;
onAcceptNecessary?: (prefs: Record<string, boolean>) => void;
onSave?: (prefs: Record<string, boolean>) => void;
className?: string;
}
const defaultCategories: ConsentCategory[] = [
{
id: "necessary",
label: "Strictly necessary",
description: "Required for sign-in, security, and core features.",
required: true,
},
{
id: "analytics",
label: "Analytics",
description: "Anonymous usage data that helps us improve the product.",
},
{
id: "marketing",
label: "Marketing",
description: "Personalised content and campaign measurement.",
},
];
/**
* Consent bar that behaves like furniture, not a siege: it slides up once on
* the drawer ease, offers equal-weight Accept/Necessary-only choices, and
* folds a granular per-category switch list open via grid rows. Non-required
* categories start off — consent is opt-in. Any decision slides the bar away
* for good (single mount, never re-nags); persistence is the caller's via
* the three callbacks, each receiving the final preference map.
*/
export function CookieConsent({
title = "We value your privacy",
description = "We use cookies to keep you signed in, understand usage, and improve the product. You can change this anytime in Settings.",
categories = defaultCategories,
delay = 150,
defaultExpanded = false,
onAcceptAll,
onAcceptNecessary,
onSave,
className,
}: CookieConsentProps) {
const reducedMotion = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
const [decided, setDecided] = React.useState(false);
const [expanded, setExpanded] = React.useState(defaultExpanded);
const [prefs, setPrefs] = React.useState<Record<string, boolean>>(() =>
Object.fromEntries(categories.map((c) => [c.id, Boolean(c.required)])),
);
const prefsId = React.useId();
React.useEffect(() => {
const timer = setTimeout(() => setMounted(true), delay);
return () => clearTimeout(timer);
}, [delay]);
const decide = (
finalPrefs: Record<string, boolean>,
callback?: (prefs: Record<string, boolean>) => void,
) => {
setDecided(true);
callback?.(finalPrefs);
};
const allOn = Object.fromEntries(categories.map((c) => [c.id, true]));
const necessaryOnly = Object.fromEntries(
categories.map((c) => [c.id, Boolean(c.required)]),
);
const drawerEase = [0.32, 0.72, 0, 1] as const;
return (
<AnimatePresence>
{mounted && !decided && (
<motion.section
role="region"
aria-label="Cookie preferences"
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: "120%" }}
animate={{ opacity: 1, y: 0 }}
exit={
reducedMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
opacity: 0,
y: "120%",
transition: { duration: 0.25, ease: drawerEase },
}
}
transition={{ duration: 0.35, ease: drawerEase }}
className={cn(
"w-full max-w-xl rounded-xl bg-popover p-4 text-popover-foreground shadow-overlay",
className,
)}
>
<div className="flex items-start gap-3">
<span
aria-hidden
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary text-secondary-foreground"
>
<Cookie className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-5 font-medium">{title}</p>
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
{description}
</p>
</div>
</div>
<div
id={prefsId}
className={cn(
"grid transition-[grid-template-rows,opacity] duration-250 ease-[var(--ease-out)] motion-reduce:transition-[opacity]",
expanded
? "grid-rows-[1fr] opacity-100"
: "grid-rows-[0fr] opacity-0",
)}
>
<div
className="overflow-hidden"
inert={expanded ? undefined : true}
>
<ul className="mt-3 divide-y divide-border rounded-lg bg-card shadow-border">
{categories.map((category) => (
<li
key={category.id}
className="flex items-center gap-3 px-3 py-2.5"
>
<div className="min-w-0 flex-1">
<p className="text-[13px] leading-4 font-medium">
{category.label}
{category.required && (
<span className="ml-1.5 text-[11px] font-normal text-muted-foreground">
Always on
</span>
)}
</p>
{category.description && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{category.description}
</p>
)}
</div>
<Switch
aria-label={`Allow ${category.label.toLowerCase()} cookies`}
checked={category.required || prefs[category.id] === true}
disabled={category.required}
onCheckedChange={(checked) =>
setPrefs((current) => ({
...current,
[category.id]: checked,
}))
}
className="shrink-0"
/>
</li>
))}
</ul>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="sm"
aria-expanded={expanded}
aria-controls={prefsId}
onClick={() => setExpanded((current) => !current)}
className="text-muted-foreground hover:text-foreground"
>
Manage preferences
<ChevronDown
aria-hidden
className={cn(
"transition-[rotate] duration-200 ease-[var(--ease-out)]",
expanded && "-rotate-180",
)}
/>
</Button>
<div className="ml-auto flex gap-2">
{expanded ? (
<Button
variant="outline"
size="sm"
onClick={() => decide(prefs, onSave)}
>
Save preferences
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => decide(necessaryOnly, onAcceptNecessary)}
>
Necessary only
</Button>
)}
<Button size="sm" onClick={() => decide(allOn, onAcceptAll)}>
Accept all
</Button>
</div>
</div>
</motion.section>
)}
</AnimatePresence>
);
}