Cookie Preferences
A granular cookie consent manager: one row per category with plain-language copy and a vendor-count disclosure, a locked strictly-necessary row, equal-weight accept-all / reject-all / save-selection, and a saved-choice strip that re-opens the editor.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/cookie-preferences.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CookiePreferences" block (lucide-react
icons; cn() = clsx + tailwind-merge for classes, no other dependency). It is the
granular half of a cookie banner: the screen where a visitor answers per category
instead of pressing one Accept button, plus the compact strip that lets them come
back and change that answer. It stores nothing — it emits the category map and the
host owns the cookie, the storage and the script gating.
Contract
- forwardRef<HTMLElement>, root <section>, extends
Omit<React.HTMLAttributes<HTMLElement>, "defaultValue" | "title">; remaining
props spread on the root.
- categories: CookieCategory[] = { id; label; description (plain language: what
this bucket does and what breaks without it); required?; requiredReason?;
vendors?: CookieVendor[]; vendorCount?; defaultEnabled? }.
- CookieVendor = { id; name; purpose?; retention? (PRE-FORMATTED, "13 months" —
the block formats no durations); privacyHref?; mark?: ReactNode }. `mark` is the
vendor's own brand mark and ALWAYS arrives from the consumer: the block ships no
third-party logo and hardcodes no brand colour.
- value?: Record<categoryId, boolean> | null — the SAVED decision. null or
undefined means "has not decided yet", which is a different claim from
"everything is off": undecided opens the editor, decided shows the saved strip.
- savedAt?: string | null (ISO instant) with locale ("en-US") and timeZone
("UTC"). That instant is the ONLY clock in the component. Never call Date.now():
a value derived from render time makes SSR and hydration disagree and makes every
screenshot different.
- onSave?(decision): void | Promise<void>, where decision = { categories:
Record<id, boolean>; origin: "accept-all" | "reject-all" | "save-selection";
allowedIds; deniedIds }. origin travels with the payload because a preset and a
hand-picked selection are not the same consent event and a receipt has to say
which one happened.
- onDraftChange?(categoryId, allowed) fires on every hand toggle and explicitly
means "not saved yet".
- Presentation: title? / description? (ReactNode; pass null to drop the header and
embed under an existing heading), policyHref? + policyLabel? (no href, no link —
never a link to nowhere), defaultOpen?, defaultExpandedCategoryIds?
(uncontrolled initial values only), showVendors? (true), and the five button
labels (accept / reject / save / cancel / reopen).
- Export the arithmetic so a footer badge or a consent receipt cannot grow a second
opinion about what was allowed: normalizeConsent(categories, value),
presetConsent(categories, preset, base), vendorDisclosureCount(category), and
summarizeConsent(categories, values) -> { total, optional, optionalAllowed,
allowedIds, deniedIds, allowedLabels, deniedLabels, vendors, sentence }.
Behavior — the consent rules
- CONSENT IS OPT-IN. A category with no stored answer renders OFF unless
defaultEnabled says otherwise. A pre-ticked box is not consent.
- THE LOCK LIVES IN THE DATA. required: true renders on, aria-disabled, with a lock
glyph in the knob and an "Always on" chip, and is forced true in every emitted
map — so neither Reject all nor a hand-written `value` can route around it.
Activating it ANNOUNCES requiredReason instead of doing nothing: a switch that
silently ignores a press reads as broken.
- normalizeConsent keeps ids it does not render. A category owned by another
surface has to survive a round trip through this panel instead of being quietly
revoked.
- vendorDisclosureCount = max(vendors.length, vendorCount): a sample list can never
lower a declared total, and the expanded list then ends with "Showing 5 of 34".
Zero is stated out loud ("No third-party vendors") — silence is not a disclosure.
With a count but no list, the count is static text, not a button that expands
nothing.
- Reject all and Accept all carry the SAME visual weight (both outline, Save is the
only primary). An easy yes beside a de-emphasised no is a dark pattern, and in
several jurisdictions it is not valid consent.
Behavior — commit, refusal and the branches
- Save is EXPLICIT. Toggling a switch only edits a local draft; nothing leaves the
component until Accept all, Reject all or Save selection is pressed. Accept all
and Reject all are preset-plus-commit in one press.
- One shot per press: the guard is a ref read AND written synchronously inside the
handler, because a state flag is only visible after a re-render and a double
click lands before that. Release it when the promise settles.
- Wrap the handler as new Promise(resolve => resolve(onSave(decision))), NOT
Promise.resolve(onSave(decision)): the latter cannot catch a handler that throws
synchronously, and the panel would spin forever on a bug in the host's first line.
- Resolved: adopt the committed map as the new baseline, collapse to the saved
strip, flash a "Preferences saved" tick that retires itself after ~4s, and
announce the outcome.
- Rejected: keep the editor open, KEEP THE DRAFT (rolling it back would erase what
the visitor just asked for), print the reason plus what is still in effect ("Your
saved choice is unchanged: only strictly necessary cookies are allowed."), and
re-arm the button. Pull the sentence out of the rejection with a helper that
falls back when the reason is not an Error.
- Save selection with a decision already stored and nothing changed is REFUSED:
aria-disabled plus a handler guard that announces "No changes to save", never the
native disabled attribute. Before the first decision the same press is allowed —
saving the defaults IS a decision and must be emitted.
- Three degenerate branches, each first-class: categories: [] renders an
explanatory panel instead of an empty card with an Accept all under it; every
category required renders the rows and one sentence saying there is nothing to opt
out of (no presets, no Save — three buttons that all do nothing is worse); no
onSave at all makes the panel a read-only disclosure that shows the stored values,
keeps the vendor lists expandable and drops the footer.
- A baseline that moves under the panel (a refetch, another tab, an admin) is
adopted during render against a previous-value signature, never in an effect — an
effect paints one frame of the old account's choices first. An OPEN editor keeps
its draft: the visitor is mid-sentence, and "dirty" simply re-measures against the
new baseline.
Behavior — keyboard, ARIA and focus
- Root <section> is labelled by the heading (aria-label "Cookie preferences" when
title is null) and carries aria-busy while a save is in flight.
- The editor is a role="group" aria-label="Cookie categories" with tabIndex={-1},
so it can be focused deliberately without ever entering the tab order.
- Each switch is a <button role="switch" aria-checked> named by the category label
through aria-labelledby, with the description, the vendor count and the lock
reason attached via aria-describedby instead of stuffed into the name.
- The vendor disclosure is <button aria-expanded aria-controls>; the list is
toggled with the `hidden` ATTRIBUTE, not a collapsed 0fr grid track — a 0fr track
still holds its tab stops, which turns the panel into an invisible keyboard trap.
- Tab / Shift+Tab move between switches, disclosures and footer buttons: this is a
list of buttons, not a composite widget, so no roving tabindex. Enter / Space
activate natively.
- Escape closes the innermost open thing first: an expanded vendor list collapses
and the row calls stopPropagation, so a second press backs out of the editor (and
a surrounding dialog still gets the third).
- FOCUS IS HANDED OVER, NEVER DROPPED. Collapsing a vendor list moves focus to its
disclosure button, because `hidden` would otherwise blur a focused link inside it
to <body>. Committing or cancelling unmounts the footer, so focus is claimed by
the "Change your choice" button; pressing that button unmounts the strip, so focus
is claimed by the editor group. Park the request in a ref and claim it in an
effect that runs after the commit which mounts the successor — nothing is focused
on the first paint.
- One polite sr-only role="status" region, keyed by a counter so the same sentence
twice (two refused presses) is announced twice instead of being swallowed as
"unchanged". Every refusal says what is still true, not just that it failed.
- Cleanup: the saved-flash timer is the only timer; clear it on unmount and before
re-arming it. Guard the settled promise with a mounted ref. There are no
listeners, no observers and no rAF to leak.
Rendering & styling
- Semantic tokens only, no hex / rgb() / oklch() anywhere: bg-card + border +
rounded-xl for the panel and the strip, divide-y between rows, bg-muted/40 for the
vendor list, bg-primary for an allowed switch, bg-input for a blocked one,
bg-primary/40 or bg-muted plus ring-border for a locked or read-only one (dimmed,
but knob position AND tint both keep carrying the value), text-destructive for a
failed save, text-muted-foreground for every supporting line, ring-ring
focus-visible rings with ring-offset-card.
- Rows are label + chip on top, description under it, switch on the right; the text
column is min-w-0 and everything the host writes gets wrap-anywhere, so a
70-character vendor name wraps instead of widening the card.
- Reduced motion: motion-reduce:transition-none on the knob and the chevron,
motion-reduce:animate-none on the pending spinner. Nothing about the panel stops
working when motion is off — the values are carried by knob position and text.
- cn() merges the consumer's className into the root section.
Customization levers
- Copy is the product here. description, requiredReason, each category description
and each vendor purpose are the sentences a person actually reads before
consenting — write them for your privacy notice, not for your tag manager.
- Density: showVendors={false} drops the disclosure line and leaves four clean
rows; title={null} and description={null} drop the header when the panel sits
under an existing settings heading; policyHref omitted paints no link at all.
- Categories are data: three buckets or eight, any ids you like. Mark more than one
as required if your legal basis says so — the lock, the chip, the forced true and
the refusal announcement all follow from that one flag.
- Save model: this block commits explicitly. For save-as-you-toggle instead, call
your persistence from onDraftChange and drop the footer — but then add per-switch
pending and per-switch rollback, because failure is no longer a single event.
- Presets: keep Accept all and Reject all equal, or pass labels your jurisdiction
expects ("Allow all" / "Use necessary only"). Removing Reject all is the one
change that turns this into a non-compliant pattern.
- Consent record: savedAt + locale + timeZone drive the record line only; feed a
fresh savedAt whenever you feed a new value so the two never disagree. Extend the
emitted decision with a version or a policy hash if you re-ask when the policy
changes.
- Presentation of the strip: it is the re-open entry point, so it is also what a
footer "Cookie settings" link should scroll to — or lift it out entirely, keep
onSave, and render the editor inside your own Dialog.Concepts
- Granular consent, opt-in by default — every optional category renders off until the visitor says otherwise, and the answer is per category rather than one Accept for the whole site. A pre-ticked box is the classic way to collect a consent that is not one.
- The lock is a data property —
required: trueis what makes a row on, locked and forced true in the emitted map; Reject all, a hand-writtenvalueand a bulk write all pass through the same normaliser, so there is no path that can switch strictly-necessary off. Activating it announces the reason instead of doing nothing, because a silent switch reads as broken. - Disclosure that cannot shrink — the vendor number is
max(listed, declared), so a five-item sample under a declared 34 still says 34 and labels itself "Showing 5 of 34"; zero is stated out loud rather than left blank, since silence is not a disclosure. - Explicit commit with a one-shot gate — nothing leaves the component until a button is pressed, and that press is guarded by a ref read and written synchronously inside the handler, so a double click emits one decision. A refusal keeps the draft on screen and names what is still in effect, instead of erasing what the visitor just asked for.
- Focus is handed over, never dropped — collapsing a vendor list, committing, cancelling and re-opening each unmount or hide the control the visitor was standing on, so each one names its successor: the disclosure button, the re-open button, the editor group. The request is parked in a ref and claimed by the effect that runs after the commit which mounts that successor.
- The clock is an input, persistence is the host's — the saved-record line is formatted from an injected
savedAt, so server and client render the same string, and the block writes no cookie and gates no script: it emits the category map and your application decides what that permits.
App Download
A get-the-app block that promotes the store matching the visitor's device, encodes the smart link into a scannable QR for desktop visitors, and renders the store badges you supply instead of redrawing brand marks.
Subscription Manager
A manage-plan block — the running plan with its renewal countdown and seat overage, usage against the plan's own limits, prorated plan changes, and a cancellation that states what is lost and when access actually ends.