{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cookie-consent",
  "type": "registry:ui",
  "title": "Cookie Consent",
  "description": "Compact consent card with equal choices and categories that unfold in place.",
  "categories": [
    "notifications"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/cookie-consent.tsx",
      "content": "\"use client\";\nimport { Collapsible } from \"@base-ui/react/collapsible\";\nimport { Switch } from \"@base-ui/react/switch\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronDown } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\n\nexport type CookieCategory = {\n  id: string;\n  label: string;\n  description?: string;\n  /** Needed for the site to work. Shown as always on, never toggled. */\n  required?: boolean;\n  /** Pre-set for Customize only. Accept and Reject ignore it. Leave off for opt-in. */\n  defaultChecked?: boolean;\n};\n\nexport type CookieConsentValue = Record<string, boolean>;\nexport type CookieDecision = \"accept-all\" | \"reject-non-essential\" | \"custom\";\n\ntype Stored = { consent: CookieConsentValue; decision: CookieDecision; at: string | null };\n\n// ---------------------------------------------------------------------------\n// Storage. The decision lives in localStorage and is read after hydration:\n// the server renders nothing, so people who already chose never see a flash.\n\nconst EVENT = \"stealth-cookie-consent-change\";\nconst cache = new Map<string, { raw: string | null; value: Stored | null }>();\n\nfunction readStored(key: string): Stored | null {\n  let raw: string | null = null;\n  try {\n    raw = window.localStorage.getItem(key);\n  } catch {\n    return null;\n  }\n  // useSyncExternalStore needs a stable snapshot for unchanged storage.\n  const hit = cache.get(key);\n  if (hit && hit.raw === raw) return hit.value;\n  let value: Stored | null = null;\n  try {\n    value = raw ? (JSON.parse(raw) as Stored) : null;\n  } catch {}\n  cache.set(key, { raw, value });\n  return value;\n}\n\nfunction writeStored(key: string, value: Stored) {\n  try {\n    window.localStorage.setItem(key, JSON.stringify(value));\n  } catch {\n    // Blocked storage: the choice still applies for this visit through onDecide.\n  }\n  window.dispatchEvent(new Event(EVENT));\n}\n\nfunction subscribe(fn: () => void) {\n  window.addEventListener(\"storage\", fn);\n  window.addEventListener(EVENT, fn);\n  return () => {\n    window.removeEventListener(\"storage\", fn);\n    window.removeEventListener(EVENT, fn);\n  };\n}\n\nconst hydratedStore = { subscribe: () => () => {}, client: () => true, server: () => false };\n\n/**\n * Read the saved choice anywhere: gate analytics on `consent.analytics`, or\n * call `reopen()` from a Cookie settings link to ask again with the last\n * choices filled in.\n */\nexport function useCookieConsent(storageKey = \"cookie-consent\") {\n  const stored = useSyncExternalStore(subscribe, () => readStored(storageKey), () => null);\n  const reopen = useCallback(() => {\n    const prev = readStored(storageKey);\n    writeStored(storageKey, { consent: prev?.consent ?? {}, decision: prev?.decision ?? \"custom\", at: null });\n  }, [storageKey]);\n  return { consent: stored?.consent ?? null, decision: stored?.decision ?? null, decided: !!stored?.at, reopen };\n}\n\n// ---------------------------------------------------------------------------\n\nexport type CookieConsentProps = Omit<React.ComponentProps<\"section\">, \"title\" | \"children\"> & {\n  categories: CookieCategory[];\n  title?: React.ReactNode;\n  /** The short explanation. Keep it to two lines. */\n  children?: React.ReactNode;\n  policyHref?: string;\n  policyLabel?: string;\n  /** Called once per decision with every category's state. */\n  onDecide?: (consent: CookieConsentValue, decision: CookieDecision) => void;\n  storageKey?: string;\n  position?: \"bottom-left\" | \"bottom-center\" | \"bottom-right\";\n  /** Place inside the nearest positioned ancestor instead of fixed to the window. */\n  contained?: boolean;\n  /** Open with the categories showing. */\n  defaultExpanded?: boolean;\n};\n\nconst place = {\n  \"bottom-left\": \"left-4\",\n  \"bottom-center\": \"left-1/2 -translate-x-1/2\",\n  \"bottom-right\": \"right-4\",\n} as const;\n\nexport function CookieConsent({\n  categories,\n  title = \"Cookies\",\n  children,\n  policyHref,\n  policyLabel = \"Cookie policy\",\n  onDecide,\n  storageKey = \"cookie-consent\",\n  position = \"bottom-left\",\n  contained = false,\n  defaultExpanded = false,\n  className,\n  ...rest\n}: CookieConsentProps) {\n  const reduce = useReducedMotion();\n  const { consent: saved, decided } = useCookieConsent(storageKey);\n  const hydrated = useSyncExternalStore(hydratedStore.subscribe, hydratedStore.client, hydratedStore.server);\n  const optional = categories.filter((c) => !c.required);\n  const initial = useCallback(\n    () => Object.fromEntries(categories.map((c) => [c.id, c.required ? true : (saved?.[c.id] ?? c.defaultChecked ?? false)])),\n    [categories, saved],\n  );\n\n  const [choices, setChoices] = useState<CookieConsentValue>(initial);\n  const [expanded, setExpanded] = useState(defaultExpanded);\n  // Set while the switches flip to show what the button meant, just before the card leaves.\n  const [settling, setSettling] = useState(false);\n  const titleId = useId();\n  const bodyId = useId();\n  const ref = useRef<HTMLElement>(null);\n  const returnTo = useRef<HTMLElement | null>(null);\n  const timer = useRef(0);\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  const shown = hydrated && !decided;\n\n  // When it asks again, start from what was saved last time.\n  const [lastShown, setLastShown] = useState(shown);\n  if (shown !== lastShown) {\n    setLastShown(shown);\n    if (shown) {\n      setChoices(initial());\n      setSettling(false);\n    }\n  }\n\n  const decide = (decision: CookieDecision) => {\n    if (settling) return;\n    const consent: CookieConsentValue =\n      decision === \"custom\"\n        ? { ...choices }\n        : Object.fromEntries(categories.map((c) => [c.id, c.required || decision === \"accept-all\"]));\n    const finish = () => {\n      if (ref.current?.contains(document.activeElement) && returnTo.current?.isConnected) returnTo.current.focus({ preventScroll: true });\n      writeStored(storageKey, { consent, decision, at: new Date().toISOString() });\n      onDecide?.(consent, decision);\n    };\n    // With the categories open and something to flip, flip first so the choice is visible.\n    const changes = optional.some((c) => choices[c.id] !== consent[c.id]);\n    if (expanded && changes && !reduce) {\n      setChoices(consent);\n      setSettling(true);\n      timer.current = window.setTimeout(finish, 320 + optional.length * 45);\n    } else finish();\n  };\n\n  const card = (\n    <motion.section\n      ref={ref}\n      role=\"region\"\n      aria-labelledby={titleId}\n      aria-describedby={bodyId}\n      onFocus={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) returnTo.current = e.relatedTarget as HTMLElement | null;\n      }}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.98 }}\n      animate={{ opacity: 1, y: 0, scale: 1, transition: { duration: 0.36, ease: ease.out, delay: 0.15 } }}\n      exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, y: 12, transition: { duration: 0.2, ease: ease.in } }}\n      className={cn(\n        \"@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\",\n        // Short windows scroll inside the card instead of pushing it off the top.\n        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\",\n        place[position],\n        className,\n      )}\n      {...(rest as React.ComponentProps<typeof motion.section>)}\n    >\n      <h2 id={titleId} className=\"text-[14px] font-medium tracking-[-0.015em] text-fg\">\n        {title}\n      </h2>\n      <p id={bodyId} className=\"mt-1 text-[12.5px] leading-[18px] text-fg-2 text-pretty\">\n        {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.\"}\n        {policyHref && (\n          <>\n            {\" \"}\n            <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\">\n              {policyLabel}\n            </a>\n          </>\n        )}\n      </p>\n\n      <Collapsible.Root open={expanded} onOpenChange={setExpanded}>\n        <Collapsible.Panel\n          className={cn(\n            \"h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-280 ease-in-out-quart motion-reduce:transition-none\",\n            \"data-starting-style:h-0 data-ending-style:h-0\",\n          )}\n        >\n          <ul className=\"mt-3 flex flex-col divide-y divide-line rounded-lg border border-line\" aria-label=\"Cookie categories\">\n            {categories.map((c) => (\n              <CategoryRow\n                key={c.id}\n                category={c}\n                checked={c.required ? true : !!choices[c.id]}\n                delay={settling ? optional.findIndex((o) => o.id === c.id) * 45 : 0}\n                disabled={settling}\n                onChange={(on) => setChoices((prev) => ({ ...prev, [c.id]: on }))}\n              />\n            ))}\n          </ul>\n          <ConsentButton primary className=\"mt-2 w-full\" onClick={() => decide(\"custom\")}>\n            Save my choices\n          </ConsentButton>\n        </Collapsible.Panel>\n\n        {/* Equal weight, side by side; stacked when the card is too narrow to show both labels whole. */}\n        <div className=\"mt-3 grid grid-cols-2 gap-2 @max-[340px]:grid-cols-1\">\n          <ConsentButton onClick={() => decide(\"reject-non-essential\")}>Reject non-essential</ConsentButton>\n          <ConsentButton onClick={() => decide(\"accept-all\")}>Accept all</ConsentButton>\n        </div>\n\n        {/* One persistent toggle, so focus stays put; both labels share a cell so it never changes width. */}\n        <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]\">\n          <span className=\"grid\">\n            <span aria-hidden={expanded} className={cn(\"col-start-1 row-start-1 text-center transition-opacity duration-150\", expanded && \"opacity-0\")}>\n              Customize\n            </span>\n            <span aria-hidden={!expanded} className={cn(\"col-start-1 row-start-1 text-center transition-opacity duration-150\", !expanded && \"opacity-0\")}>\n              Hide categories\n            </span>\n          </span>\n          <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\" />\n        </Collapsible.Trigger>\n      </Collapsible.Root>\n    </motion.section>\n  );\n\n  return <AnimatePresence>{shown && card}</AnimatePresence>;\n}\n\nfunction CategoryRow({\n  category,\n  checked,\n  delay,\n  disabled,\n  onChange,\n}: {\n  category: CookieCategory;\n  checked: boolean;\n  delay: number;\n  disabled: boolean;\n  onChange: (on: boolean) => void;\n}) {\n  const id = useId();\n  return (\n    <li className=\"flex items-start gap-3 px-3 py-2\">\n      <div className=\"min-w-0 flex-1\">\n        {category.required ? (\n          <span className=\"text-[13px] leading-[18px] text-fg\">{category.label}</span>\n        ) : (\n          <label htmlFor={id} className=\"cursor-pointer text-[13px] leading-[18px] text-fg\">\n            {category.label}\n          </label>\n        )}\n        {category.description && (\n          <p id={`${id}-d`} className=\"mt-0.5 text-[12px] leading-4 text-fg-3 text-pretty\">\n            {category.description}\n          </p>\n        )}\n      </div>\n      {category.required ? (\n        <span className=\"mt-px shrink-0 text-[11.5px] leading-4 text-fg-3\">\n          Always on\n        </span>\n      ) : (\n        <Switch.Root\n          id={id}\n          checked={checked}\n          onCheckedChange={onChange}\n          disabled={disabled}\n          aria-describedby={category.description ? `${id}-d` : undefined}\n          className={cn(\n            \"relative mt-px inline-flex h-[18px] w-8 shrink-0 items-center rounded-full p-0.5 outline-none\",\n            \"transition-[background-color] duration-200 ease-out-expo motion-reduce:transition-none\",\n            \"bg-fg-4 hover:bg-fg-3 data-checked:bg-fg data-checked:hover:bg-fg/90\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            \"after:absolute after:-inset-x-2 after:-inset-y-3 after:content-['']\",\n            \"data-disabled:cursor-default\",\n          )}\n          style={{ transitionDelay: `${delay}ms` }}\n        >\n          <Switch.Thumb\n            className={cn(\n              \"size-3.5 rounded-full bg-raised shadow-[0_1px_2px_rgb(0_0_0/0.25)] dark:bg-fg-2\",\n              \"transition-[translate,background-color] duration-260 ease-out-expo motion-reduce:transition-none\",\n              \"data-checked:translate-x-3.5 data-checked:bg-frame\",\n            )}\n            style={{ transitionDelay: `${delay}ms` }}\n          />\n        </Switch.Root>\n      )}\n    </li>\n  );\n}\n\nfunction ConsentButton({ primary, className, children, ...rest }: React.ComponentProps<\"button\"> & { primary?: boolean }) {\n  return (\n    <button\n      type=\"button\"\n      className={cn(\n        \"inline-flex h-8 min-w-0 items-center justify-center rounded-lg px-3 text-[12.5px] font-medium outline-none\",\n        \"transition-[background-color,border-color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        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\",\n        className,\n      )}\n      {...rest}\n    >\n      <span className=\"truncate\">{children}</span>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/cookie-consent"
}
