{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cookie-consent",
  "title": "Cookie Consent",
  "description": "Asks which categories of cookie may be used, remembers the answer — the refusals included — and holds the tags back until it has one. Reach for it on anything public that measures its visitors: a marketing or landing page, a documentation site with analytics on it, a signup funnel, a site facing the EU or UK under the GDPR and the ePrivacy Directive or Brazil under the LGPD, a site that has to honour Global Privacy Control for California, the \"Cookie settings\" link in a footer, and the prompt that has to go back to everybody after a new vendor is added. Common asks it answers: \"cookie consent react\", \"cookie banner component\", \"shadcn cookie consent\", \"gdpr cookie banner react\", \"cookie consent next.js\", \"cookie consent app router\", \"consent management react\", \"cookie preferences modal\", \"granular cookie consent categories\", \"accept reject cookies component\", \"cookie consent localStorage\", \"remember cookie choice\", \"store cookie consent decision\", \"load google analytics after consent\", \"defer gtag until consent\", \"conditionally load script after consent react\", \"block analytics until consent\", \"consent mode react\", \"do not track react\", \"navigator.doNotTrack\", \"globalPrivacyControl react\", \"respect GPC signal\", \"honour do not track javascript\", \"cookie settings link footer\", \"reopen cookie preferences\", \"withdraw cookie consent\", \"re-ask consent after adding a category\", \"consent version bump\", \"cookie banner hydration mismatch\", \"cookie banner flashes on every page load\", \"banner reappears after accepting\", \"cookie consent 同意\", \"cookie バナー react\", \"GDPR 同意 コンポーネント\", \"同意するまで解析タグを止める\". **The banner is the easy half, and a banner on its own is decoration.** Asking the question changes nothing about what the page already did: a tag in the document `<head>`, a `<script>` in a root layout, a module imported for its side effects — all of it has fetched and fired before anybody was asked, and no banner can un-send a request. So the gate ships with the question. `<ConsentGate category=\"analytics\"><Script src=\"…\" /></ConsentGate>` renders nothing until that category is granted, which means the element is never created and the script is never fetched; `useConsent(\"analytics\")` is the same answer as a boolean for a branch that is not a subtree. That is the whole honest claim, and the line it draws is drawn in the open: this reaches what React renders and nothing else. Unmounting on withdrawal is the same story in reverse — removing a `<script>` element does not unload what it installed, so withdrawal stops the next page rather than this one, and if that matters you reload after it. **A refusal is an answer, and it is written down as explicitly as a grant.** The common implementation stores `cookiesAccepted = true` on the agreement and nothing at all on the refusal, so the only visitor it remembers is the one who said yes and the one who said no is asked again on every page of every visit — the nagging is aimed precisely at the people who already declined. What is stored here is every category and its answer, false ones included, so a refusal is a complete answer and the banner does not come back. **The two browser-level refusal signals are read exactly, because the value that means the opposite is truthy.** `navigator.doNotTrack` is compared against the string `\"1\"` and nothing else: `\"0\"` means \"you may track me\", and it is a non-empty string, so `if (navigator.doNotTrack)` reads an explicit permission as a refusal. `navigator.globalPrivacyControl` is feature-detected and compared against `true`, because `false` is a browser saying it has no signal to send. Neither can be assumed present — measured in Chrome 152 today, `\"globalPrivacyControl\" in navigator` is false while `doNotTrack` *is* on the object and reads `null`, so each needs its own check and absence means nothing either way. A browser carrying one of these is not asked at all, since it has already answered, and that answer is never written to storage: it is the live reading of a setting rather than a record of a choice, so when the setting goes away the question comes back. An explicit choice made in the panel outranks the signal, because a global default that silently overrides the switches in front of somebody makes those switches a lie and leaves no way for anyone to opt in. **There are three states here, not two, and the third is why nothing ever flashes.** Before storage has been read the answer is not \"no\" — it is *not yet known*, and the two have different consumers. Modelled as \"undecided\", the banner renders on the server and on the first client pass, so every returning visitor sees the banner they dismissed flash on every page load; modelled as \"granted\", the tag fires for the person who is about to refuse. Unknown is treated as already-answered by the banner and as refused by the gate, which is the only combination that is wrong in neither direction, and it also means the component renders `null` during hydration rather than reading storage mid-render and tripping a mismatch. **An answer storage would not keep still applies to this page.** A private window, a partitioned context or a full quota makes the write fail, and for those visitors *every* answer is in that state. Without somewhere to hold it, the next read of storage finds nothing and puts the banner back in front of somebody who has just answered it — on every re-render. It is held in memory instead, keyed by the question it answered, so a bumped version or a newly added category still asks. A read that throws is treated as nothing stored, which asks again: losing an answer is recoverable, acting on one nobody gave is not. **The question can change, and an old answer is then an answer to a different question.** An answer that does not cover every category currently being asked about is treated as unfinished rather than as consent to the new one — the answers it does hold become the defaults in the panel and the banner comes back for the rest. `version` is for the other case, where a category keeps its id and changes its meaning, and bumping it asks everybody again. **Withdrawing is as easy as giving, and the control that does it can live anywhere.** The panel reopens on the current answer, from a plain link in a footer, because the decision has to stay changeable and under the GDPR withdrawal cannot be harder than consent. The state of that panel is held with the decision rather than inside the banner, which is the part that looks fine and is not: a component holding `isOpen` in its own `useState` gives the footer link a panel inside the footer and leaves the mounted banner shut. Focus moves into the panel when it opens and goes back to the link when it closes; an answer takes the whole region away, and the browser decides where focus lands after that rather than this pretending to. **The refusal is one click, in the banner, beside the agreement.** \"Allow all\" and \"Necessary only\" sit together at equal weight, with the per-category panel as a third option rather than as the only route to saying no. A refusal buried a step deeper than the agreement is a dark pattern, and the regulators that require the question require this as well. The strictly-necessary category is shown with the words \"Always on\" rather than a switch that is disabled: a control that invites a click and then ignores it is worse than a sentence. The switches are `role=\"switch\"` with `aria-checked`, each labelled and described by its own category rather than by one shared id, every control is a real `type=\"button\"` so a banner inside a form cannot submit it, decorative marks are `aria-hidden`, and the region is a labelled `role=\"region\"` — not a `dialog` with `aria-modal`, which would claim the rest of the page does not exist while the page is fully usable behind it, and would be the one lie a screen reader cannot work around. An answer given in another tab arrives through the `storage` event, so a gate does not stay shut after the person consented next door. Theme tokens throughout with no hardcoded colours, one lucide icon, and nothing else added. **It is not a tag manager and it is not legal advice.** It holds a decision and gates what it is given; it does not enumerate the cookies on your origin, delete the ones already set, talk to an IAB framework, or produce a record you could show a regulator. The default category copy is a starting point and not a description of your site. Official shadcn/ui has nothing here, and the measurement is not close: fetching every entry in its registry today — 65 listed, 62 fetchable, code-block and questionnaire and trace 404 on both style tracks — and searching all 223,287 bytes of it, the strings consent, cookieConsent, doNotTrack, globalPrivacyControl, dataLayer, gtag, analytics, localStorage, categories and necessary are every one of them a zero hit. The single occurrence of `document.cookie` is `sidebar` saving whether it is open, read in context today to be sure. Within pulld it sits above announcement-bar, which shows a message and remembers that it was dismissed, and beside terms-scroll-gate, which makes somebody see a document before they can act on it. Neither of those holds a per-category decision or gates anything on one; this is the only component here whose answer other components read.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/cookie-consent.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Cookie } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst STORAGE_KEY = \"pulld-cookie-consent\"\n\n/** Whether the decision is known yet, and whether it has been made. */\nexport type ConsentStatus = \"unknown\" | \"undecided\" | \"decided\"\n\n/** Who answered. A signal is the browser's standing refusal, not a choice anybody made here. */\nexport type ConsentSource = \"user\" | \"signal\"\n\nexport interface ConsentCategory {\n  /** Stable key. This is what `ConsentGate` and `useConsent` are asked about. */\n  id: string\n  /** Shown as the switch's label. */\n  label: string\n  /** One sentence saying what is actually stored or sent. Shown under the label. */\n  description: string\n  /**\n   * Granted whether or not anybody answers, and shown without a switch.\n   *\n   * For the cookies a site cannot work without — a session, a CSRF token, the record of these\n   * very choices. There is no switch because an off position would be a lie.\n   */\n  required?: boolean\n}\n\nexport interface ConsentState {\n  status: ConsentStatus\n  /**\n   * Whether each category may be used right now. Empty while the status is `unknown`.\n   *\n   * Read it through `useConsent` rather than directly: a missing key has to count as a refusal,\n   * and `granted[id]` is `undefined` for a category nobody has configured.\n   */\n  granted: Readonly<Record<string, boolean>>\n  /** How the current answer was arrived at, or null when there is no answer. */\n  source: ConsentSource | null\n  /** When the person answered, as epoch milliseconds. Null unless `source` is `\"user\"`. */\n  decidedAt: number | null\n}\n\nconst UNKNOWN: ConsentState = Object.freeze({\n  status: \"unknown\",\n  granted: Object.freeze({}),\n  source: null,\n  decidedAt: null,\n})\n\ninterface StoredConsent {\n  /** Config version this answer was given against. */\n  v: string\n  /** Epoch milliseconds. */\n  at: number\n  /** Every optional category's answer, including the refusals. */\n  cats: Record<string, boolean>\n}\n\n// --- the signals -----------------------------------------------------------------------------\n\n/**\n * The browser's standing refusal to be tracked, or null when it is not carrying one.\n *\n * Both are feature-detected rather than assumed. Measured in Chrome 152 (2026-11-05):\n * `globalPrivacyControl` is **not on `navigator` at all**, while `doNotTrack` **is** there and\n * reads `null`. So neither can be read without checking, and neither absence means anything.\n *\n * `doNotTrack` is compared against the string `\"1\"` and nothing else, because the value that\n * means the opposite — `\"0\"`, \"you may track me\" — is a non-empty string and therefore truthy.\n * `if (navigator.doNotTrack)` reads an explicit permission as a refusal, which is the one way to\n * get this backwards while looking right.\n *\n * `globalPrivacyControl` is compared against `true` for the same shape of reason: it is specified\n * as a boolean, and `false` is a browser saying it has no signal to send, not a browser consenting.\n */\nexport function refusalSignal(): \"gpc\" | \"dnt\" | null {\n  if (typeof navigator === \"undefined\") return null\n  const nav = navigator as Navigator & {\n    globalPrivacyControl?: boolean\n    doNotTrack?: string | null\n  }\n  if (nav.globalPrivacyControl === true) return \"gpc\"\n  if (nav.doNotTrack === \"1\") return \"dnt\"\n  return null\n}\n\n// --- the store -------------------------------------------------------------------------------\n//\n// One decision per browser, held outside React, because that is what it is: every gate in the\n// tree is asking the same question and there is exactly one answer. A module-level store also\n// means `ConsentGate` works wherever it is dropped, with no provider to wrap the app in — the\n// thing people forget, and forgetting it here would mean a gate that renders its child because\n// no context was found.\n//\n// On the server this module is shared between requests, which would be a leak if anything ever\n// wrote to it there. Nothing does: `hydrate` only ever runs from an effect, so a server render\n// sees `UNKNOWN` and nothing else, and every client starts from `UNKNOWN` too.\n\nlet state: ConsentState = UNKNOWN\n\n// Whether the panel of switches is showing, which belongs here for the same reason the answer\n// does: the \"Cookie settings\" link that reopens it is in a footer, nowhere near the banner it has\n// to open. Held in a component's own `useState` instead — which is the obvious place — that link\n// opens a panel inside the footer and leaves the mounted banner shut.\nlet panelOpen = false\n\n// The element to hand focus back to when the panel closes. Not part of the snapshot: it is one\n// node, not state anything renders from.\nlet opener: HTMLElement | null = null\n\n// What the hooks hold. Rebuilt only when something changed, because the hooks keep it in\n// `useState` and an equal-but-new object on every pass would be an endless render.\nlet snapshot: { consent: ConsentState; panelOpen: boolean } = { consent: state, panelOpen }\nconst listeners = new Set<() => void>()\n\nfunction notify() {\n  snapshot = { consent: state, panelOpen }\n  for (const listener of [...listeners]) listener()\n}\n\nfunction sameGrants(a: Readonly<Record<string, boolean>>, b: Readonly<Record<string, boolean>>) {\n  const keys = Object.keys(a)\n  if (keys.length !== Object.keys(b).length) return false\n  return keys.every((key) => a[key] === b[key])\n}\n\n/**\n * Replaces the snapshot, but only when something actually changed.\n *\n * The identity has to be stable across an unchanged recompute, because the subscribing hooks keep\n * the snapshot in `useState`: handing them an equal-but-new object on every pass would be an\n * endless render. It is also why `granted` is compared by value rather than by reference.\n */\nfunction setState(next: ConsentState) {\n  if (\n    next.status === state.status &&\n    next.source === state.source &&\n    next.decidedAt === state.decidedAt &&\n    sameGrants(next.granted, state.granted)\n  ) {\n    return\n  }\n  state = next\n  notify()\n}\n\nfunction setPanelOpen(next: boolean) {\n  if (next === panelOpen) return\n  panelOpen = next\n  notify()\n}\n\n/** The element that opened the panel, cleared as it is handed over. */\nfunction takeOpener(): HTMLElement | null {\n  const element = opener\n  opener = null\n  return element\n}\n\n/** The current decision, without React. Referentially stable until it changes. */\nexport function readConsent(): ConsentState {\n  return state\n}\n\n/**\n * Called whenever the store changes, which is the decision — including an answer from another tab\n * — and the panel opening or closing. Returns an unsubscribe. Read the current value with\n * `readConsent`; this is the notification, not the value.\n */\nexport function subscribeConsent(listener: () => void): () => void {\n  listeners.add(listener)\n  return () => {\n    listeners.delete(listener)\n  }\n}\n\n// --- storage ---------------------------------------------------------------------------------\n\nfunction readStored(storageKey: string): StoredConsent | null {\n  let raw: string | null = null\n  try {\n    raw = window.localStorage.getItem(storageKey)\n  } catch {\n    // Blocked or partitioned storage. Treated as \"nothing stored\", which means asking again —\n    // the only safe direction, since the alternative is assuming a grant nobody gave.\n    return null\n  }\n  if (!raw) return null\n  try {\n    const parsed: unknown = JSON.parse(raw)\n    if (!parsed || typeof parsed !== \"object\") return null\n    const record = parsed as Partial<StoredConsent>\n    if (typeof record.v !== \"string\" || typeof record.at !== \"number\") return null\n    if (!record.cats || typeof record.cats !== \"object\") return null\n    const cats: Record<string, boolean> = {}\n    for (const [key, value] of Object.entries(record.cats)) {\n      if (typeof value === \"boolean\") cats[key] = value\n    }\n    return { v: record.v, at: record.at, cats }\n  } catch {\n    return null\n  }\n}\n\n/** Whether the answer was written down. False means it applies to this page and no further. */\nfunction writeStored(storageKey: string, record: StoredConsent): boolean {\n  try {\n    window.localStorage.setItem(storageKey, JSON.stringify(record))\n    return true\n  } catch {\n    // Losing the answer is recoverable — they get asked again next load. Acting on an answer we\n    // never got is not, and neither is putting the banner back in front of somebody who just\n    // answered it: see `unsaved`.\n    return false\n  }\n}\n\n// The question an unsaved answer was given about, or null when the last answer was written down.\n//\n// Storage that refuses a write is not rare — a private window, a partitioned third-party context,\n// a full quota — and for those visitors *every* answer is in this state. Without this, the next\n// read of storage finds nothing and puts the banner back in front of somebody who has just\n// answered it, which is how a blocked-storage visitor ends up answering on every single render.\n// It is keyed by the question so that changing the question still asks: an answer that could not\n// be saved is not a reason to skip a new category or a bumped version.\nlet unsaved: string | null = null\n\n// --- resolving the configuration --------------------------------------------------------------\n\nexport const DEFAULT_CATEGORIES: readonly ConsentCategory[] = Object.freeze([\n  Object.freeze({\n    id: \"necessary\",\n    label: \"Strictly necessary\",\n    description:\n      \"Needed for the site to work at all: signing in, keeping you signed in, security, and remembering the choices on this screen.\",\n    required: true,\n  }),\n  Object.freeze({\n    id: \"analytics\",\n    label: \"Analytics\",\n    description: \"Lets us count visits and see which pages are used, in aggregate.\",\n  }),\n  Object.freeze({\n    id: \"marketing\",\n    label: \"Marketing\",\n    description: \"Lets us measure campaigns and show you ads elsewhere based on this visit.\",\n  }),\n])\n\nexport interface UseCookieConsentOptions {\n  /**\n   * The categories to ask about. The first `required` one is listed without a switch.\n   *\n   * Defaults to strictly necessary / analytics / marketing. Keep the ids stable: they are the\n   * keys in storage, and renaming one throws away the answer it held.\n   */\n  categories?: readonly ConsentCategory[]\n  /**\n   * Bump when the *meaning* of a category changes, to ask everybody again.\n   *\n   * Adding a category does not need it — an answer that does not cover every current category is\n   * already treated as unfinished — but repurposing one does, because the old answer was given\n   * about something else.\n   */\n  version?: string | number\n  /** localStorage key. Change it only to run two independent banners on one origin. */\n  storageKey?: string\n  /** Called after every answer, with the categories that may now be used. */\n  onDecision?: (granted: Readonly<Record<string, boolean>>, source: ConsentSource) => void\n}\n\ninterface ResolvedConfig {\n  categories: readonly ConsentCategory[]\n  optional: string[]\n  version: string\n  storageKey: string\n}\n\nfunction resolveConfig(options: UseCookieConsentOptions): ResolvedConfig {\n  const categories = options.categories ?? DEFAULT_CATEGORIES\n  return {\n    categories,\n    optional: categories.filter((c) => !c.required).map((c) => c.id),\n    version: options.version == null ? \"1\" : String(options.version),\n    storageKey: options.storageKey ?? STORAGE_KEY,\n  }\n}\n\n/** Identifies the question being asked, so an answer to a different one is not reused. */\nfunction questionOf(config: ResolvedConfig) {\n  return [config.version, config.storageKey, ...config.optional].join(\"\\u0000\")\n}\n\n/** Required categories are granted in every state but `unknown`; optional ones start refused. */\nfunction grantsFrom(config: ResolvedConfig, answers: Record<string, boolean>) {\n  const granted: Record<string, boolean> = {}\n  for (const category of config.categories) {\n    granted[category.id] = category.required ? true : answers[category.id] === true\n  }\n  return granted\n}\n\n/**\n * Works out the current state from storage and the browser, and puts it in the store.\n *\n * Idempotent on purpose — it runs on mount, on every config change, and again whenever another\n * tab writes — so `setState` discards an unchanged result rather than notifying anybody.\n */\nfunction hydrate(config: ResolvedConfig) {\n  const stored = readStored(config.storageKey)\n  const answered =\n    stored !== null &&\n    stored.v === config.version &&\n    // An answer has to cover every category currently being asked about. One added since means\n    // the stored answer is not an answer to this question, so the ones it does hold become the\n    // defaults in the panel and the banner comes back.\n    config.optional.every((id) => id in stored.cats)\n\n  if (answered && stored) {\n    setState({\n      status: \"decided\",\n      granted: grantsFrom(config, stored.cats),\n      source: \"user\",\n      decidedAt: stored.at,\n    })\n    return\n  }\n\n  // An answer this session that storage would not keep. It is still the person's answer to this\n  // question, so it stands until something else answers it.\n  if (\n    unsaved === questionOf(config) &&\n    state.status === \"decided\" &&\n    state.source === \"user\"\n  ) {\n    return\n  }\n\n  // Nobody has answered. A browser carrying a refusal has already answered for everything\n  // optional, and asking it again is the nagging the signal exists to stop.\n  //\n  // It is not written to storage, because it is not a record of anything: it is this browser's\n  // setting, read fresh each load. If it is turned off later, the banner appears, which is right —\n  // a decision nobody made should not outlive the thing that made it.\n  if (refusalSignal()) {\n    setState({\n      status: \"decided\",\n      granted: grantsFrom(config, {}),\n      source: \"signal\",\n      decidedAt: null,\n    })\n    return\n  }\n\n  setState({\n    status: \"undecided\",\n    granted: grantsFrom(config, stored?.cats ?? {}),\n    source: null,\n    decidedAt: null,\n  })\n}\n\n// --- the hooks -------------------------------------------------------------------------------\n\nfunction useStore() {\n  const [held, setHeld] = React.useState(() => snapshot)\n  React.useEffect(() => {\n    // Catch a change that landed between this render and this subscription — hydration from a\n    // sibling, or another tab — then follow the store.\n    setHeld(snapshot)\n    return subscribeConsent(() => setHeld(snapshot))\n  }, [])\n  return held\n}\n\n/**\n * Whether `category` may be used right now.\n *\n * False until the answer is known, so nothing fires on the strength of not having asked yet. A\n * category nobody configured is false too, which is what makes a typo fail closed.\n */\nexport function useConsent(category: string): boolean {\n  return useStore().consent.granted[category] === true\n}\n\nexport interface CookieConsentController extends ConsentState {\n  /** The categories being asked about, resolved. */\n  categories: readonly ConsentCategory[]\n  /** Whether the panel of switches is showing. */\n  isOpen: boolean\n  /**\n   * Shows the panel of switches, pre-filled with the current answers.\n   *\n   * This is the \"Cookie settings\" link in a footer. A decision has to stay changeable, and under\n   * the GDPR withdrawing has to be as easy as giving — so this is not optional furniture.\n   */\n  open: () => void\n  /** Hides the panel without changing anything. */\n  close: () => void\n  /** Grants everything and closes. */\n  acceptAll: () => void\n  /** Refuses everything optional and closes. This is also the withdrawal path. */\n  denyAll: () => void\n  /** Records exactly these answers for the optional categories, and closes. */\n  save: (answers: Record<string, boolean>) => void\n  /** Forgets the answer, so the banner comes back. For a \"re-ask me\" control, and for tests. */\n  reset: () => void\n}\n\n/**\n * Owns the stored decision, and the controls for changing it.\n *\n * `CookieConsent` calls this, so mounting that component anywhere is enough. Build your own UI on\n * it instead if you like — but mount exactly one of them, and mount it unconditionally: until\n * something hydrates the store every `ConsentGate` is closed, which is the right way round to\n * fail, and also an easy thing to be confused by.\n */\nexport function useCookieConsent(options: UseCookieConsentOptions = {}): CookieConsentController {\n  const { onDecision } = options\n  const config = resolveConfig(options)\n  const { version, storageKey } = config\n  const optionalKey = config.optional.join(\"\\u0000\")\n\n  const held = useStore()\n\n  const configRef = React.useRef(config)\n  configRef.current = config\n\n  React.useEffect(() => {\n    const current = configRef.current\n    hydrate(current)\n    // Another tab answering is this tab's answer too; a gate that stays shut after the person\n    // consented next door is the same bug as one that never opens.\n    const onStorage = (event: StorageEvent) => {\n      if (event.key === null || event.key === current.storageKey) hydrate(current)\n    }\n    window.addEventListener(\"storage\", onStorage)\n    return () => {\n      window.removeEventListener(\"storage\", onStorage)\n    }\n    // Re-run when the question changes, not when the caller passes a fresh array of the same\n    // categories: `optionalKey` is the content of that array.\n  }, [version, storageKey, optionalKey])\n\n  const commit = React.useCallback(\n    (answers: Record<string, boolean>) => {\n      const current = configRef.current\n      const cats: Record<string, boolean> = {}\n      for (const id of current.optional) cats[id] = answers[id] === true\n      const at = Date.now()\n      const saved = writeStored(current.storageKey, { v: current.version, at, cats })\n      unsaved = saved ? null : questionOf(current)\n      setState({\n        status: \"decided\",\n        granted: grantsFrom(current, cats),\n        source: \"user\",\n        decidedAt: at,\n      })\n      setPanelOpen(false)\n      onDecision?.(readConsent().granted, \"user\")\n    },\n    [onDecision]\n  )\n\n  const acceptAll = React.useCallback(() => {\n    const answers: Record<string, boolean> = {}\n    for (const id of configRef.current.optional) answers[id] = true\n    commit(answers)\n  }, [commit])\n\n  const denyAll = React.useCallback(() => commit({}), [commit])\n\n  const reset = React.useCallback(() => {\n    const current = configRef.current\n    try {\n      window.localStorage.removeItem(current.storageKey)\n    } catch {\n      // Nothing was stored, or storage is blocked. Either way the state below is what applies.\n    }\n    // Forgetting the answer has to forget the unsaved one too, or a blocked-storage visitor can\n    // never get the question back.\n    unsaved = null\n    setPanelOpen(false)\n    hydrate(current)\n  }, [])\n\n  return {\n    ...held.consent,\n    categories: config.categories,\n    isOpen: held.panelOpen,\n    // Remembers what had focus so the banner can hand it back — the footer link that opened it,\n    // which is still there once the panel closes.\n    open: React.useCallback(() => {\n      // No `typeof document` guard: this is only ever reached from a click, so there is a document\n      // by construction, and a guard that cannot fail is a line that cannot be tested.\n      const active = document.activeElement\n      opener = active instanceof HTMLElement ? active : null\n      setPanelOpen(true)\n    }, []),\n    close: React.useCallback(() => setPanelOpen(false), []),\n    acceptAll,\n    denyAll,\n    save: commit,\n    reset,\n  }\n}\n\n// --- the gate --------------------------------------------------------------------------------\n\nexport interface ConsentGateProps {\n  /** The category whose grant this content waits for. */\n  category: string\n  /** Rendered once that category is granted, and not before. */\n  children?: React.ReactNode\n  /** Rendered while it is not — a placeholder, or a note explaining what is switched off. */\n  fallback?: React.ReactNode\n}\n\n/**\n * Renders `children` only while `category` is granted.\n *\n * **This is the half that makes the banner mean anything, and it only reaches what React renders.**\n * A tag in your document `<head>`, a `<script>` in a root layout outside a gate, or a module\n * imported for its side effects has already run by the time anybody is asked, and no banner can\n * un-send a request. Move the tag inside the gate — `<ConsentGate category=\"analytics\"><Script\n * src=\"…\" /></ConsentGate>` — and the deferral is real: the element is not rendered, so the script\n * is not fetched. Leave it where it was and this component is decoration over a site that tracks\n * people who said no.\n *\n * Unmounting on withdrawal is just as real for a script already loaded: removing a `<script>`\n * element does not unload what it installed. Withdrawal stops the next page, not this one — reload\n * after `denyAll` if that matters.\n */\nexport function ConsentGate({ category, children, fallback = null }: ConsentGateProps) {\n  return <>{useConsent(category) ? children : fallback}</>\n}\n\n// --- the UI ----------------------------------------------------------------------------------\n\nexport interface CookieConsentProps\n  extends UseCookieConsentOptions,\n    Omit<React.ComponentPropsWithoutRef<\"section\">, \"children\" | \"onChange\" | \"title\"> {\n  /** Heading on the banner. */\n  title?: React.ReactNode\n  /** The sentence under it. Say what the categories are for, not that cookies exist. */\n  description?: React.ReactNode\n  /** Your privacy policy link, rendered beside the buttons. */\n  policy?: React.ReactNode\n  /** Accessible name for the region. */\n  label?: string\n}\n\nfunction Switch({\n  checked,\n  onChange,\n  labelId,\n  describedBy,\n}: {\n  checked: boolean\n  onChange: (next: boolean) => void\n  labelId: string\n  describedBy: string\n}) {\n  return (\n    <button\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={checked}\n      aria-labelledby={labelId}\n      aria-describedby={describedBy}\n      onClick={() => onChange(!checked)}\n      className={cn(\n        \"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        checked ? \"bg-primary\" : \"bg-input\"\n      )}\n    >\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"pointer-events-none block h-4 w-4 rounded-full bg-background shadow transition-transform\",\n          checked ? \"translate-x-[1.125rem]\" : \"translate-x-0.5\"\n        )}\n      />\n    </button>\n  )\n}\n\n/**\n * Asks which categories of cookie may be used, remembers the answer — refusals included — and\n * hands it to `ConsentGate` and `useConsent`.\n *\n * Mount it once, unconditionally, near the root. It renders nothing until the answer is known, so\n * a returning visitor never sees the banner flash; it renders nothing while the browser is sending\n * a refusal signal, because that is already an answer; and it is a labelled region rather than a\n * modal dialog, so it never traps focus or holds up the first paint of the page behind it.\n */\nexport function CookieConsent({\n  categories,\n  version,\n  storageKey,\n  onDecision,\n  title = \"Cookies on this site\",\n  description = \"We use cookies that the site needs to work. With your agreement we would also like to count visits and measure our campaigns. You can change this at any time.\",\n  policy,\n  label = \"Cookie consent\",\n  className,\n  ...props\n}: CookieConsentProps) {\n  const consent = useCookieConsent({ categories, version, storageKey, onDecision })\n  const baseId = React.useId()\n  const panelRef = React.useRef<HTMLDivElement | null>(null)\n\n  const [draft, setDraft] = React.useState<Record<string, boolean>>({})\n  const [lastOpen, setLastOpen] = React.useState(false)\n\n  // The switches start from whatever is granted now, so opening the panel after a decision shows\n  // that decision rather than a fresh set of refusals.\n  if (consent.isOpen !== lastOpen) {\n    setLastOpen(consent.isOpen)\n    if (consent.isOpen) {\n      const next: Record<string, boolean> = {}\n      for (const category of consent.categories) {\n        if (!category.required) next[category.id] = consent.granted[category.id] === true\n      }\n      setDraft(next)\n    }\n  }\n\n  const close = () => {\n    consent.close()\n    // Only for a panel opened from somewhere that still exists — the footer link. After an answer\n    // the whole region unmounts and there is nothing of ours to go back to, which is the browser's\n    // business rather than something to fake.\n    takeOpener()?.focus()\n  }\n\n  React.useEffect(() => {\n    if (consent.isOpen) panelRef.current?.focus()\n  }, [consent.isOpen])\n\n  // Not \"undecided || isOpen\" — a decided visitor who opens the panel from a footer link gets the\n  // panel, and an undecided one gets the banner, but nobody gets a banner they have answered.\n  const showing = consent.status === \"undecided\" || consent.isOpen\n  if (!showing) return null\n\n  const buttonBase =\n    \"inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n\n  return (\n    <section\n      role=\"region\"\n      aria-label={label}\n      className={cn(\n        \"fixed inset-x-0 bottom-0 z-50 border-t bg-background p-4 text-foreground shadow-lg sm:inset-x-4 sm:bottom-4 sm:mx-auto sm:max-w-2xl sm:rounded-lg sm:border\",\n        className\n      )}\n      {...props}\n    >\n      {consent.isOpen ? (\n        <div ref={panelRef} tabIndex={-1} className=\"focus-visible:outline-none\">\n          <h2 id={`${baseId}-title`} className=\"text-sm font-semibold\">\n            Choose which cookies to allow\n          </h2>\n          <ul className=\"mt-3 space-y-3\">\n            {consent.categories.map((category) => {\n              const labelId = `${baseId}-${category.id}-label`\n              const descriptionId = `${baseId}-${category.id}-description`\n              return (\n                <li key={category.id} className=\"flex items-start gap-3\">\n                  <div className=\"min-w-0 flex-1\">\n                    <p id={labelId} className=\"text-sm font-medium\">\n                      {category.label}\n                    </p>\n                    <p id={descriptionId} className=\"text-sm text-muted-foreground\">\n                      {category.description}\n                    </p>\n                  </div>\n                  {category.required ? (\n                    // A disabled switch invites the person to try, then ignores them. Saying it\n                    // in words is both truer and reachable.\n                    <span className=\"shrink-0 pt-0.5 text-xs text-muted-foreground\">\n                      Always on\n                    </span>\n                  ) : (\n                    <Switch\n                      checked={draft[category.id] === true}\n                      onChange={(next) =>\n                        setDraft((current) => ({ ...current, [category.id]: next }))\n                      }\n                      labelId={labelId}\n                      describedBy={descriptionId}\n                    />\n                  )}\n                </li>\n              )\n            })}\n          </ul>\n          <div className=\"mt-4 flex flex-wrap items-center gap-2\">\n            <button\n              type=\"button\"\n              onClick={() => consent.save(draft)}\n              className={cn(buttonBase, \"bg-primary text-primary-foreground hover:bg-primary/90\")}\n            >\n              Save choices\n            </button>\n            <button\n              type=\"button\"\n              onClick={consent.acceptAll}\n              className={cn(buttonBase, \"border hover:bg-accent hover:text-accent-foreground\")}\n            >\n              Allow all\n            </button>\n            <button\n              type=\"button\"\n              onClick={consent.denyAll}\n              className={cn(buttonBase, \"border hover:bg-accent hover:text-accent-foreground\")}\n            >\n              Necessary only\n            </button>\n            {consent.status === \"decided\" ? (\n              <button\n                type=\"button\"\n                onClick={close}\n                className={cn(buttonBase, \"px-2 text-muted-foreground hover:text-foreground\")}\n              >\n                Cancel\n              </button>\n            ) : null}\n          </div>\n        </div>\n      ) : (\n        <div className=\"flex flex-col gap-3 sm:flex-row sm:items-start\">\n          <Cookie className=\"hidden h-5 w-5 shrink-0 text-muted-foreground sm:block\" aria-hidden=\"true\" />\n          <div className=\"min-w-0 flex-1\">\n            <h2 className=\"text-sm font-semibold\">{title}</h2>\n            <p className=\"mt-1 text-sm text-muted-foreground\">{description}</p>\n            {policy ? <p className=\"mt-1 text-sm\">{policy}</p> : null}\n          </div>\n          {/* Equal weight on purpose. A refusal that is harder to find than the agreement is a\n              dark pattern, and the regulators that require the question also require this. */}\n          <div className=\"flex shrink-0 flex-wrap gap-2\">\n            <button\n              type=\"button\"\n              onClick={consent.acceptAll}\n              className={cn(buttonBase, \"bg-primary text-primary-foreground hover:bg-primary/90\")}\n            >\n              Allow all\n            </button>\n            <button\n              type=\"button\"\n              onClick={consent.denyAll}\n              className={cn(buttonBase, \"border hover:bg-accent hover:text-accent-foreground\")}\n            >\n              Necessary only\n            </button>\n            <button\n              type=\"button\"\n              onClick={consent.open}\n              className={cn(buttonBase, \"px-2 text-muted-foreground hover:text-foreground\")}\n            >\n              Choose\n            </button>\n          </div>\n        </div>\n      )}\n    </section>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
