Browse the library
Components / Feedback
Cookie consent
FreeFloating consent card with Accept all, Reject and customizable Analytics and Marketing toggles.
components/facade/cookie-consent.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown, Key, Shield } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ConsentState {
necessary: true;
analytics: boolean;
marketing: boolean;
}
export type ConsentAction = "accept-all" | "reject" | "save";
export interface CookieConsentProps {
/** Called whenever the visitor makes a choice. */
onChange?: (consent: ConsentState, action: ConsentAction) => void;
/** Show the card. Uncontrolled default: shown after `delay` unless a stored choice exists. */
open?: boolean;
/** Delay before the card appears, in ms. */
delay?: number;
/** localStorage key to remember the choice. Set to null to disable persistence. */
storageKey?: string | null;
title?: string;
description?: string;
policyHref?: string;
/** Position absolute inside the nearest positioned parent instead of fixed to the viewport. */
contained?: boolean;
className?: string;
}
const CATEGORIES = [
{
key: "necessary",
label: "Strictly necessary",
description: "Required for booking, forms and security. Always on.",
locked: true,
},
{
key: "analytics",
label: "Analytics",
description: "Helps us understand which pages are useful so we can improve them.",
locked: false,
},
{
key: "marketing",
label: "Marketing",
description: "Lets us measure ads and show you relevant offers on other sites.",
locked: false,
},
] as const;
function Toggle({
checked,
onChange,
disabled,
labelledBy,
describedBy,
}: {
checked: boolean;
onChange?: (v: boolean) => void;
disabled?: boolean;
labelledBy: string;
describedBy: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-labelledby={labelledBy}
aria-describedby={describedBy}
disabled={disabled}
onClick={() => onChange?.(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full p-0.5 transition-colors duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
checked ? "bg-brand" : "bg-foreground/15",
disabled && "cursor-not-allowed opacity-60",
)}
>
<motion.span
className="size-4 rounded-full bg-card shadow-sm"
animate={{ x: checked ? 16 : 0 }}
transition={{ type: "spring", stiffness: 500, damping: 34 }}
/>
</button>
);
}
export function CookieConsent({
onChange,
open: openProp,
delay = 600,
storageKey = "facade-cookie-consent",
title = "We value your privacy",
description = "We use cookies to run this site, understand traffic and personalize offers. You can change your choice anytime.",
policyHref = "#privacy",
contained = false,
className,
}: CookieConsentProps) {
const reduce = useReducedMotion();
const [shown, setShown] = React.useState(false);
const [expanded, setExpanded] = React.useState(false);
const [prefs, setPrefs] = React.useState({ analytics: false, marketing: false });
const uid = React.useId();
const open = openProp ?? shown;
React.useEffect(() => {
if (openProp !== undefined) return;
let stored = false;
if (storageKey) {
try {
stored = window.localStorage.getItem(storageKey) !== null;
} catch {
stored = false;
}
}
if (stored) return;
const id = window.setTimeout(() => setShown(true), delay);
return () => window.clearTimeout(id);
}, [openProp, delay, storageKey]);
const decide = (consent: ConsentState, action: ConsentAction) => {
if (storageKey) {
try {
window.localStorage.setItem(storageKey, JSON.stringify(consent));
} catch {
/* storage unavailable, keep going */
}
}
onChange?.(consent, action);
setShown(false);
};
const acceptAll = () => decide({ necessary: true, analytics: true, marketing: true }, "accept-all");
const reject = () => decide({ necessary: true, analytics: false, marketing: false }, "reject");
const save = () => decide({ necessary: true, ...prefs }, "save");
return (
<AnimatePresence>
{open && (
<motion.section
role="dialog"
aria-modal="false"
aria-labelledby={`${uid}-title`}
aria-describedby={`${uid}-desc`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.98, transition: { duration: 0.2 } }}
transition={{ type: "spring", stiffness: 380, damping: 32 }}
style={{ transformOrigin: "bottom left" }}
className={cn(
contained ? "absolute" : "fixed",
"bottom-4 left-4 right-4 z-50 sm:right-auto sm:w-[380px]",
"rounded-2xl border border-border bg-card/95 p-5 text-card-foreground shadow-2xl shadow-foreground/15 backdrop-blur-xl",
className,
)}
>
<div className="flex items-start gap-3">
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-brand/10 text-brand">
<Shield className="size-[18px]" aria-hidden />
</span>
<div className="min-w-0">
<h2 id={`${uid}-title`} className="text-sm font-semibold tracking-tight">
{title}
</h2>
<p id={`${uid}-desc`} className="mt-1 text-[13px] leading-relaxed text-muted-foreground">
{description}{" "}
<a
href={policyHref}
className="rounded font-medium text-foreground underline decoration-foreground/30 underline-offset-4 hover:decoration-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Privacy policy
</a>
</p>
</div>
</div>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls={`${uid}-prefs`}
className="mt-4 flex w-full items-center justify-between rounded-lg px-1 py-1 text-[13px] font-medium text-foreground/80 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Customize preferences
<motion.span animate={{ rotate: expanded ? 180 : 0 }} transition={{ duration: 0.25 }}>
<ChevronDown className="size-4" aria-hidden />
</motion.span>
</button>
<AnimatePresence initial={false}>
{expanded && (
<motion.div
id={`${uid}-prefs`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.3, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden"
>
<ul className="mt-2 divide-y divide-border rounded-xl border border-border bg-background/60">
{CATEGORIES.map((c) => {
const checked = c.key === "necessary" ? true : prefs[c.key];
return (
<li key={c.key} className="flex items-start justify-between gap-4 p-3">
<div className="min-w-0">
<p id={`${uid}-${c.key}-l`} className="flex items-center gap-1.5 text-[13px] font-medium">
{c.label}
{c.locked && <Key className="size-3 text-muted-foreground" aria-hidden />}
</p>
<p id={`${uid}-${c.key}-d`} className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
{c.description}
</p>
</div>
<Toggle
checked={checked}
disabled={c.locked}
labelledBy={`${uid}-${c.key}-l`}
describedBy={`${uid}-${c.key}-d`}
onChange={
c.key === "necessary"
? undefined
: (v) => setPrefs((p) => ({ ...p, [c.key]: v }))
}
/>
</li>
);
})}
</ul>
</motion.div>
)}
</AnimatePresence>
<div className="mt-4 grid grid-cols-2 gap-2">
{expanded ? (
<>
<button
type="button"
onClick={save}
className="h-9 rounded-full border border-border text-[13px] font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Save choices
</button>
<button
type="button"
onClick={acceptAll}
className="h-9 rounded-full bg-primary text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
Accept all
</button>
</>
) : (
<>
<button
type="button"
onClick={reject}
className="h-9 rounded-full border border-border text-[13px] font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Reject
</button>
<button
type="button"
onClick={acceptAll}
className="h-9 rounded-full bg-primary text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
Accept all
</button>
</>
)}
</div>
</motion.section>
)}
</AnimatePresence>
);
}
export default CookieConsent;app/page.tsx
"use client";
import * as React from "react";
import { Building2, CircleCheck } from "lucide-react";
import { CookieConsent, type ConsentState } from "@/components/facade/cookie-consent";
export default function Demo() {
const [run, setRun] = React.useState(0);
const [result, setResult] = React.useState<ConsentState | null>(null);
return (
<div className="mx-auto w-full max-w-3xl p-4">
<div className="relative h-[420px] overflow-hidden rounded-2xl border border-border bg-background">
{/* Fake page behind the card */}
<header className="flex h-12 items-center justify-between border-b border-border px-5">
<span className="flex items-center gap-2 text-sm font-semibold tracking-tight">
<Building2 className="size-4 text-brand" aria-hidden />
Harbor & Pine Realty
</span>
<span className="hidden text-xs text-muted-foreground sm:block">Buy · Sell · Rent · Charleston, SC</span>
</header>
<div className="grid gap-4 p-5 sm:grid-cols-3">
{["$685,000", "$1,240,000", "$449,900"].map((p, i) => (
<div key={p} className="rounded-xl border border-border bg-card p-3">
<div
className="aspect-[4/3] rounded-lg bg-[linear-gradient(135deg,var(--accent),var(--muted))]"
style={{ opacity: 1 - i * 0.12 }}
/>
<p className="mt-2 font-mono text-sm font-semibold">{p}</p>
<p className="text-xs text-muted-foreground">{["3 bd · 2 ba · Mount Pleasant", "4 bd · 3.5 ba · South of Broad", "2 bd · 2 ba · Daniel Island"][i]}</p>
</div>
))}
</div>
{result && (
<div className="absolute right-4 top-16 flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 text-xs shadow-sm">
<CircleCheck className="size-3.5 text-success" aria-hidden />
Analytics {result.analytics ? "on" : "off"} · Marketing {result.marketing ? "on" : "off"}
<button
type="button"
onClick={() => {
setResult(null);
setRun((r) => r + 1);
}}
className="ml-1 rounded font-medium text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Reset
</button>
</div>
)}
<CookieConsent
key={run}
contained
storageKey={null}
delay={400}
onChange={(c) => setResult(c)}
/>
</div>
</div>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/cookie-consent.jsonUsage
import { CookieConsent } from "@/components/facade/cookie-consent";
export default function Page() {
return <CookieConsent />;
}Props
CookieConsentProps is exported, so you can extend or wrap it. Every prop has a default; <CookieConsent /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| onChange | (consent: ConsentState, action: ConsentAction) => void | Called whenever the visitor makes a choice. |
| open | boolean | Show the card. Uncontrolled default: shown after `delay` unless a stored choice exists. |
| delay | number | Delay before the card appears, in ms. |
| storageKey | string | null | localStorage key to remember the choice. Set to null to disable persistence. |
| title | string | |
| description | string | |
| policyHref | string | |
| contained | boolean | Position absolute inside the nearest positioned parent instead of fixed to the viewport. |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade cookie consent to this page and adapt the copy to our business.