Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Check,
  ChevronDown,
  CircleDashed,
  Cookie,
  ExternalLink,
  LoaderCircle,
  Lock,
  ShieldCheck,
  SlidersHorizontal,
  TriangleAlert,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/cookie-preferences.json

Prompt

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 propertyrequired: true is what makes a row on, locked and forced true in the emitted map; Reject all, a hand-written value and 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.

On This Page