Compact consent card with equal choices and categories that unfold in place.
01Preview
Guides
Deploy on push
Connect a repository and every push to main builds and ships. Preview deploys open for each pull request.
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react motion03Usage
import { CookieConsent, useCookieConsent } from "@/components/ui/cookie-consent";
<CookieConsent
policyHref="/legal/cookies"
categories={[
{ id: "essential", label: "Essential", description: "Sign-in and security.", required: true },
{ id: "analytics", label: "Analytics", description: "Anonymous usage, so we know what to fix." },
{ id: "marketing", label: "Marketing", description: "Measures our ads on other sites." },
]}
onDecide={(consent) => consent.analytics && loadAnalytics()}
/>
// Anywhere else: gate scripts, or ask again from the footer.
const { consent, reopen } = useCookieConsent();
<button onClick={reopen}>Cookie settings</button>04Source
"use client";
import { Collapsible } from "@base-ui/react/collapsible";
import { Switch } from "@base-ui/react/switch";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ChevronDown } from "@/lib/icons";
import { ease } from "@/lib/motion";
export type CookieCategory = {
id: string;
label: string;
description?: string;
/** Needed for the site to work. Shown as always on, never toggled. */
required?: boolean;
/** Pre-set for Customize only. Accept and Reject ignore it. Leave off for opt-in. */
defaultChecked?: boolean;
};
export type CookieConsentValue = Record<string, boolean>;
export type CookieDecision = "accept-all" | "reject-non-essential" | "custom";
type Stored = { consent: CookieConsentValue; decision: CookieDecision; at: string | null };
// ---------------------------------------------------------------------------
// Storage. The decision lives in localStorage and is read after hydration:
// the server renders nothing, so people who already chose never see a flash.
const EVENT = "stealth-cookie-consent-change";
const cache = new Map<string, { raw: string | null; value: Stored | null }>();
function readStored(key: string): Stored | null {
let raw: string | null = null;
try {
raw = window.localStorage.getItem(key);
} catch {
return null;
}
// useSyncExternalStore needs a stable snapshot for unchanged storage.
const hit = cache.get(key);
if (hit && hit.raw === raw) return hit.value;
let value: Stored | null = null;
try {
value = raw ? (JSON.parse(raw) as Stored) : null;
} catch {}
cache.set(key, { raw, value });
return value;
}
function writeStored(key: string, value: Stored) {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// Blocked storage: the choice still applies for this visit through onDecide.
}
window.dispatchEvent(new Event(EVENT));
}
function subscribe(fn: () => void) {
window.addEventListener("storage", fn);
window.addEventListener(EVENT, fn);
return () => {
window.removeEventListener("storage", fn);
window.removeEventListener(EVENT, fn);
};
}
const hydratedStore = { subscribe: () => () => {}, client: () => true, server: () => false };
/**
* Read the saved choice anywhere: gate analytics on `consent.analytics`, or
* call `reopen()` from a Cookie settings link to ask again with the last
* choices filled in.
*/
export function useCookieConsent(storageKey = "cookie-consent") {
const stored = useSyncExternalStore(subscribe, () => readStored(storageKey), () => null);
const reopen = useCallback(() => {
const prev = readStored(storageKey);
writeStored(storageKey, { consent: prev?.consent ?? {}, decision: prev?.decision ?? "custom", at: null });
}, [storageKey]);
return { consent: stored?.consent ?? null, decision: stored?.decision ?? null, decided: !!stored?.at, reopen };
}
// ---------------------------------------------------------------------------
export type CookieConsentProps = Omit<React.ComponentProps<"section">, "title" | "children"> & {
categories: CookieCategory[];
title?: React.ReactNode;
/** The short explanation. Keep it to two lines. */
children?: React.ReactNode;
policyHref?: string;
policyLabel?: string;
/** Called once per decision with every category's state. */
onDecide?: (consent: CookieConsentValue, decision: CookieDecision) => void;
storageKey?: string;
position?: "bottom-left" | "bottom-center" | "bottom-right";
/** Place inside the nearest positioned ancestor instead of fixed to the window. */
contained?: boolean;
/** Open with the categories showing. */
defaultExpanded?: boolean;
};
const place = {
"bottom-left": "left-4",
"bottom-center": "left-1/2 -translate-x-1/2",
"bottom-right": "right-4",
} as const;
export function CookieConsent({
categories,
title = "Cookies",
children,
policyHref,
policyLabel = "Cookie policy",
onDecide,
storageKey = "cookie-consent",
position = "bottom-left",
contained = false,
defaultExpanded = false,
className,
...rest
}: CookieConsentProps) {
const reduce = useReducedMotion();
const { consent: saved, decided } = useCookieConsent(storageKey);
const hydrated = useSyncExternalStore(hydratedStore.subscribe, hydratedStore.client, hydratedStore.server);
const optional = categories.filter((c) => !c.required);
const initial = useCallback(
() => Object.fromEntries(categories.map((c) => [c.id, c.required ? true : (saved?.[c.id] ?? c.defaultChecked ?? false)])),
[categories, saved],
);
const [choices, setChoices] = useState<CookieConsentValue>(initial);
const [expanded, setExpanded] = useState(defaultExpanded);
// Set while the switches flip to show what the button meant, just before the card leaves.
const [settling, setSettling] = useState(false);
const titleId = useId();
const bodyId = useId();
const ref = useRef<HTMLElement>(null);
const returnTo = useRef<HTMLElement | null>(null);
const timer = useRef(0);
useEffect(() => () => window.clearTimeout(timer.current), []);
const shown = hydrated && !decided;
// When it asks again, start from what was saved last time.
const [lastShown, setLastShown] = useState(shown);
if (shown !== lastShown) {
setLastShown(shown);
if (shown) {
setChoices(initial());
setSettling(false);
}
}
const decide = (decision: CookieDecision) => {
if (settling) return;
const consent: CookieConsentValue =
decision === "custom"
? { ...choices }
: Object.fromEntries(categories.map((c) => [c.id, c.required || decision === "accept-all"]));
const finish = () => {
if (ref.current?.contains(document.activeElement) && returnTo.current?.isConnected) returnTo.current.focus({ preventScroll: true });
writeStored(storageKey, { consent, decision, at: new Date().toISOString() });
onDecide?.(consent, decision);
};
// With the categories open and something to flip, flip first so the choice is visible.
const changes = optional.some((c) => choices[c.id] !== consent[c.id]);
if (expanded && changes && !reduce) {
setChoices(consent);
setSettling(true);
timer.current = window.setTimeout(finish, 320 + optional.length * 45);
} else finish();
};
const card = (
<motion.section
ref={ref}
role="region"
aria-labelledby={titleId}
aria-describedby={bodyId}
onFocus={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) returnTo.current = e.relatedTarget as HTMLElement | null;
}}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1, transition: { duration: 0.36, ease: ease.out, delay: 0.15 } }}
exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, y: 12, transition: { duration: 0.2, ease: ease.in } }}
className={cn(
"@container z-(--z-toast) w-[min(380px,calc(100%-32px))] origin-bottom overflow-y-auto overscroll-contain rounded-xl border border-line-2 bg-raised p-4 shadow-pop",
// Short windows scroll inside the card instead of pushing it off the top.
contained ? "absolute bottom-4 max-h-[calc(100%-32px)]" : "fixed bottom-[max(16px,env(safe-area-inset-bottom))] max-h-[calc(100dvh-32px)] max-sm:inset-x-4 max-sm:w-auto max-sm:translate-x-0",
place[position],
className,
)}
{...(rest as React.ComponentProps<typeof motion.section>)}
>
<h2 id={titleId} className="text-[14px] font-medium tracking-[-0.015em] text-fg">
{title}
</h2>
<p id={bodyId} className="mt-1 text-[12.5px] leading-[18px] text-fg-2 text-pretty">
{children ?? "We use essential cookies to run the site. With your OK, we’d also like to measure how it’s used so we can improve it."}
{policyHref && (
<>
{" "}
<a href={policyHref} className="text-fg underline decoration-fg-4 underline-offset-[3px] outline-none transition-[text-decoration-color] duration-150 hover:decoration-fg-2 focus-visible:rounded-sm focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3">
{policyLabel}
</a>
</>
)}
</p>
<Collapsible.Root open={expanded} onOpenChange={setExpanded}>
<Collapsible.Panel
className={cn(
"h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-280 ease-in-out-quart motion-reduce:transition-none",
"data-starting-style:h-0 data-ending-style:h-0",
)}
>
<ul className="mt-3 flex flex-col divide-y divide-line rounded-lg border border-line" aria-label="Cookie categories">
{categories.map((c) => (
<CategoryRow
key={c.id}
category={c}
checked={c.required ? true : !!choices[c.id]}
delay={settling ? optional.findIndex((o) => o.id === c.id) * 45 : 0}
disabled={settling}
onChange={(on) => setChoices((prev) => ({ ...prev, [c.id]: on }))}
/>
))}
</ul>
<ConsentButton primary className="mt-2 w-full" onClick={() => decide("custom")}>
Save my choices
</ConsentButton>
</Collapsible.Panel>
{/* Equal weight, side by side; stacked when the card is too narrow to show both labels whole. */}
<div className="mt-3 grid grid-cols-2 gap-2 @max-[340px]:grid-cols-1">
<ConsentButton onClick={() => decide("reject-non-essential")}>Reject non-essential</ConsentButton>
<ConsentButton onClick={() => decide("accept-all")}>Accept all</ConsentButton>
</div>
{/* One persistent toggle, so focus stays put; both labels share a cell so it never changes width. */}
<Collapsible.Trigger className="group/cust mt-2 flex h-8 w-full items-center justify-center gap-1 rounded-lg text-[12.5px] text-fg-2 outline-none transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 active:scale-[0.98]">
<span className="grid">
<span aria-hidden={expanded} className={cn("col-start-1 row-start-1 text-center transition-opacity duration-150", expanded && "opacity-0")}>
Customize
</span>
<span aria-hidden={!expanded} className={cn("col-start-1 row-start-1 text-center transition-opacity duration-150", !expanded && "opacity-0")}>
Hide categories
</span>
</span>
<ChevronDown size={14} className="text-fg-3 transition-transform duration-280 ease-in-out-quart group-data-panel-open/cust:rotate-180 motion-reduce:transition-none" />
</Collapsible.Trigger>
</Collapsible.Root>
</motion.section>
);
return <AnimatePresence>{shown && card}</AnimatePresence>;
}
function CategoryRow({
category,
checked,
delay,
disabled,
onChange,
}: {
category: CookieCategory;
checked: boolean;
delay: number;
disabled: boolean;
onChange: (on: boolean) => void;
}) {
const id = useId();
return (
<li className="flex items-start gap-3 px-3 py-2">
<div className="min-w-0 flex-1">
{category.required ? (
<span className="text-[13px] leading-[18px] text-fg">{category.label}</span>
) : (
<label htmlFor={id} className="cursor-pointer text-[13px] leading-[18px] text-fg">
{category.label}
</label>
)}
{category.description && (
<p id={`${id}-d`} className="mt-0.5 text-[12px] leading-4 text-fg-3 text-pretty">
{category.description}
</p>
)}
</div>
{category.required ? (
<span className="mt-px shrink-0 text-[11.5px] leading-4 text-fg-3">
Always on
</span>
) : (
<Switch.Root
id={id}
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
aria-describedby={category.description ? `${id}-d` : undefined}
className={cn(
"relative mt-px inline-flex h-[18px] w-8 shrink-0 items-center rounded-full p-0.5 outline-none",
"transition-[background-color] duration-200 ease-out-expo motion-reduce:transition-none",
"bg-fg-4 hover:bg-fg-3 data-checked:bg-fg data-checked:hover:bg-fg/90",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"after:absolute after:-inset-x-2 after:-inset-y-3 after:content-['']",
"data-disabled:cursor-default",
)}
style={{ transitionDelay: `${delay}ms` }}
>
<Switch.Thumb
className={cn(
"size-3.5 rounded-full bg-raised shadow-[0_1px_2px_rgb(0_0_0/0.25)] dark:bg-fg-2",
"transition-[translate,background-color] duration-260 ease-out-expo motion-reduce:transition-none",
"data-checked:translate-x-3.5 data-checked:bg-frame",
)}
style={{ transitionDelay: `${delay}ms` }}
/>
</Switch.Root>
)}
</li>
);
}
function ConsentButton({ primary, className, children, ...rest }: React.ComponentProps<"button"> & { primary?: boolean }) {
return (
<button
type="button"
className={cn(
"inline-flex h-8 min-w-0 items-center justify-center rounded-lg px-3 text-[12.5px] font-medium outline-none",
"transition-[background-color,border-color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
primary ? "bg-fg text-frame hover:bg-fg/90" : "border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover",
className,
)}
{...rest}
>
<span className="truncate">{children}</span>
</button>
);
}05Props
CookieConsent
| Prop | Type | Default | Description |
|---|---|---|---|
| categories* | { id; label; description?; required?; defaultChecked? }[] | — | Required categories show as Always on. Everything else is off until chosen. |
| title | ReactNode | "Cookies" | The card's heading. |
| children | ReactNode | — | The explanation. Defaults to a plain two-line one. |
| policyHref | string | — | Adds an inline link to the policy. |
| policyLabel | string | "Cookie policy" | The link's text. |
| onDecide | (consent: Record<string, boolean>, decision: "accept-all" | "reject-non-essential" | "custom") => void | — | Called once per decision with every category's state. |
| storageKey | string | "cookie-consent" | Where the decision is kept in localStorage, with a timestamp. |
| position | "bottom-left" | "bottom-center" | "bottom-right" | "bottom-left" | Corner of the window. Full width with 16px gutters on phones. |
| contained | boolean | false | Position inside the nearest positioned ancestor instead of the window. |
| defaultExpanded | boolean | false | Open with the categories showing. |
useCookieConsent
| Prop | Type | Default | Description |
|---|---|---|---|
| storageKey | string | "cookie-consent" | Returns { consent, decision, decided, reopen }. reopen() shows the card again with the last choices filled in. |
06Notes
Behavior
- Reject non-essential and Accept all are the same size, weight and style, side by side; on a card narrower than 340px they stack rather than truncate.
- Customize unfolds the categories in place and adds Save my choices under them; the same toggle folds them away, so focus never loses its place.
- The decision is read after hydration and the server renders nothing, so someone who already chose never sees the card flash. On short windows the card scrolls inside itself.
- With categories open, Accept all or Reject flips the switches first, 45ms apart, so the person sees exactly what they agreed to before the card leaves.
Motion
- Enters 150ms after load: up 16px from 0.98 scale with a 360ms expo ease-out. Leaves down 12px in 200ms.
- Categories grow to their measured height over 280ms on the in-out quart curve while the chevron turns; switch thumbs slide 14px in 260ms.
- Reduced motion fades the card in and out, opens categories instantly and skips the flip-before-leaving pause.
Accessibility
- A labeled region, not a modal: it never traps focus or blocks the page, and it is described by its explanation.
- Each category switch is labeled by its name and described by its explanation; required categories are text, not a disabled control.
- After a choice, focus returns to where it was before entering the card. Every button is a real button with a verb.