{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cookie-consent",
  "title": "Cookie Consent",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A privacy banner with equal-weight Accept all and Reject non-essential, plus a preferences view with a switch per category and locked required ones.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/motion-presets.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/cookie-consent.tsx",
      "content": "// Ballmac UI: Cookie Consent. https://ui.ballmac.com/components/cookie-consent\n\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronLeft, Cookie, Lock } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { spring } from \"@/lib/ballmac/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { useMessages } from \"@/lib/ballmac/i18n\";\n\ntype ConsentCategory = {\n  /** Key used in the saved choice, for example \"analytics\". */\n  id: string;\n  /** Name shown in preferences. */\n  label: string;\n  /** What this category is used for. */\n  description: string;\n  /** Always on and cannot be switched off (for strictly necessary cookies). */\n  required?: boolean;\n};\n\ntype ConsentChoice = Record<string, boolean>;\n\ntype CookieConsentProps = Omit<React.ComponentProps<\"section\">, \"title\" | \"onChange\"> & {\n  /** Cookie categories. Put required ones first. */\n  categories: ConsentCategory[];\n  /** Called with the choice when the visitor decides. */\n  onConsent?: (choice: ConsentChoice) => void;\n  /**\n   * localStorage key. When set, the banner stays hidden once a choice is saved there and the choice is saved for you.\n   * Leave undefined to manage visibility yourself with `open`.\n   */\n  storageKey?: string;\n  /** Controlled visibility. Overrides the stored state. */\n  open?: boolean;\n  /** Banner heading. */\n  title?: string;\n  /** Short explanation. */\n  description?: React.ReactNode;\n  /** Link to your cookie or privacy policy. */\n  policyHref?: string;\n  /** Where the banner sits. */\n  placement?: \"bottom-left\" | \"bottom-right\" | \"bottom-center\";\n};\n\nfunction readStored(key?: string): ConsentChoice | null {\n  if (!key) return null;\n  try {\n    const raw = window.localStorage.getItem(key);\n    return raw ? (JSON.parse(raw) as ConsentChoice) : null;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * A privacy banner with three equal choices: Accept all, Reject non-essential, and Customize. Accept and Reject have the\n * same visual weight, and the preferences view lists each category with its own switch. Required categories are shown as locked.\n * Focus moves to the new view when you open preferences, and the banner never blocks the page.\n */\nfunction CookieConsent({\n  categories,\n  onConsent,\n  storageKey,\n  open: openProp,\n  title,\n  description,\n  policyHref,\n  placement = \"bottom-left\",\n  className,\n  ...props\n}: CookieConsentProps) {\n  const msg = useMessages()\n  title ??= msg(\"cookie-consent.title\", \"We value your privacy\")\n  description ??= msg(\"cookie-consent.description\", \"We use cookies to make the site work, understand how it is used and, with your permission, personalize content.\")\n  const reduce = useReducedMotion();\n  const [view, setView] = React.useState<\"summary\" | \"preferences\">(\"summary\");\n  const [decided, setDecided] = React.useState<boolean | null>(null);\n  const [draft, setDraft] = React.useState<ConsentChoice>(() =>\n    Object.fromEntries(categories.map((c) => [c.id, !!c.required])),\n  );\n  const headingRef = React.useRef<HTMLHeadingElement>(null);\n\n  React.useEffect(() => {\n    // Reading storage must wait for the browser, so the server and first render agree.\n    const frame = requestAnimationFrame(() => setDecided(readStored(storageKey) !== null));\n    return () => cancelAnimationFrame(frame);\n  }, [storageKey]);\n\n  const visible = openProp ?? (decided === false);\n\n  function decide(choice: ConsentChoice) {\n    if (storageKey) {\n      try {\n        window.localStorage.setItem(storageKey, JSON.stringify(choice));\n      } catch {\n        /* storage unavailable: the choice still reaches onConsent */\n      }\n    }\n    setDecided(true);\n    onConsent?.(choice);\n  }\n  const all = (value: boolean) => Object.fromEntries(categories.map((c) => [c.id, c.required ? true : value]));\n\n  function show(next: \"summary\" | \"preferences\") {\n    setView(next);\n    requestAnimationFrame(() => headingRef.current?.focus());\n  }\n\n  return (\n    <AnimatePresence>\n      {visible && (\n        <motion.section\n          role=\"dialog\"\n          aria-modal=\"false\"\n          aria-labelledby=\"cookie-consent-title\"\n          data-slot=\"cookie-consent\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.98 }}\n          animate={{ opacity: 1, y: 0, scale: 1 }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, y: 24, scale: 0.98 }}\n          transition={reduce ? { duration: 0.1 } : spring.gentle}\n          className={cn(\n            \"fixed bottom-4 z-50 max-h-[calc(100dvh-2rem)] w-[min(26rem,calc(100vw-2rem))] overflow-y-auto rounded-2xl border bg-popover text-popover-foreground shadow-[0_24px_64px_-16px_rgb(0_0_0/0.35)]\",\n            placement === \"bottom-left\" && \"start-4\",\n            placement === \"bottom-right\" && \"end-4\",\n            placement === \"bottom-center\" && \"left-1/2 -translate-x-1/2\",\n            className,\n          )}\n          {...(props as object)}\n        >\n          <div className=\"grid gap-4 p-5\">\n            <div className=\"flex items-start gap-3\">\n              <span className=\"flex size-10 shrink-0 items-center justify-center rounded-xl bg-chart-3/15 text-chart-3\">\n                <Cookie aria-hidden=\"true\" className=\"size-5\" />\n              </span>\n              <div className=\"min-w-0 flex-1\">\n                <h2 id=\"cookie-consent-title\" ref={headingRef} tabIndex={-1} className=\"text-base font-semibold tracking-tight outline-none\">\n                  {view === \"summary\" ? title : \"Cookie preferences\"}\n                </h2>\n                {view === \"summary\" && (\n                  <p className=\"mt-1 text-sm leading-relaxed text-muted-foreground\">\n                    {description}\n                    {policyHref && (\n                      <>\n                        {\" \"}\n                        <a href={policyHref} className=\"rounded-sm font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\">\n                          {msg(\"cookie-consent.cookiePolicy\", \"Cookie policy\")}\n                        </a>\n                        .\n                      </>\n                    )}\n                  </p>\n                )}\n              </div>\n            </div>\n\n            {view === \"preferences\" && (\n              <ul className=\"grid gap-2\">\n                {categories.map((c) => {\n                  const id = `cookie-${c.id}`;\n                  const on = c.required ? true : !!draft[c.id];\n                  return (\n                    <li key={c.id} className=\"flex items-start gap-3 rounded-xl border bg-background/60 p-3\">\n                      <div className=\"min-w-0 flex-1\">\n                        <label htmlFor={id} className=\"flex items-center gap-1.5 text-sm font-medium\">\n                          {c.label}\n                          {c.required && (\n                            <span className=\"inline-flex items-center gap-1 rounded-full bg-muted px-1.5 py-px text-[10px] font-medium text-muted-foreground\">\n                              <Lock aria-hidden=\"true\" className=\"size-2.5\" /> {msg(\"cookie-consent.alwaysOn\", \"Always on\")}\n                            </span>\n                          )}\n                        </label>\n                        <p className=\"mt-0.5 text-xs leading-snug text-muted-foreground\">{c.description}</p>\n                      </div>\n                      <button\n                        id={id}\n                        type=\"button\"\n                        role=\"switch\"\n                        aria-checked={on}\n                        disabled={c.required}\n                        onClick={() => setDraft((d) => ({ ...d, [c.id]: !d[c.id] }))}\n                        className={cn(\n                          \"relative mt-0.5 inline-flex h-5 w-9 shrink-0 items-center rounded-full outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60\",\n                          on ? \"bg-primary\" : \"bg-input\",\n                        )}\n                      >\n                        <span\n                          aria-hidden=\"true\"\n                          className={cn(\n                            \"size-4 rounded-full bg-background shadow transition-transform duration-200 motion-reduce:transition-none\",\n                            on ? \"translate-x-[18px] rtl:-translate-x-[18px]\" : \"translate-x-0.5 rtl:-translate-x-0.5\",\n                          )}\n                        />\n                      </button>\n                    </li>\n                  );\n                })}\n              </ul>\n            )}\n\n            <div className={cn(\"grid gap-2\", view === \"summary\" ? \"grid-cols-2\" : \"grid-cols-1\")}>\n              {view === \"summary\" ? (\n                <>\n                  <button\n                    type=\"button\"\n                    onClick={() => decide(all(false))}\n                    className=\"h-9 rounded-md border bg-background text-sm font-medium shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                  >\n                    {msg(\"cookie-consent.rejectNonEssential\", \"Reject non-essential\")}\n                  </button>\n                  <button\n                    type=\"button\"\n                    onClick={() => decide(all(true))}\n                    className=\"h-9 rounded-md border bg-background text-sm font-medium shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                  >\n                    {msg(\"cookie-consent.acceptAll\", \"Accept all\")}\n                  </button>\n                  <button\n                    type=\"button\"\n                    onClick={() => show(\"preferences\")}\n                    className=\"col-span-2 h-9 rounded-md text-sm font-medium text-muted-foreground underline-offset-4 outline-none hover:text-foreground hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                  >\n                    {msg(\"cookie-consent.customize\", \"Customize\")}\n                  </button>\n                </>\n              ) : (\n                <>\n                  <button\n                    type=\"button\"\n                    onClick={() => decide({ ...draft, ...Object.fromEntries(categories.filter((c) => c.required).map((c) => [c.id, true])) })}\n                    className=\"h-9 rounded-md bg-primary text-sm font-medium text-primary-foreground shadow-xs outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                  >\n                    {msg(\"cookie-consent.savePreferences\", \"Save preferences\")}\n                  </button>\n                  <button\n                    type=\"button\"\n                    onClick={() => show(\"summary\")}\n                    className=\"inline-flex h-9 items-center justify-center gap-1 rounded-md text-sm font-medium text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                  >\n                    <ChevronLeft aria-hidden=\"true\" className=\"size-4 rtl:rotate-180\" /> {msg(\"cookie-consent.back\", \"Back\")}\n                  </button>\n                </>\n              )}\n            </div>\n          </div>\n        </motion.section>\n      )}\n    </AnimatePresence>\n  );\n}\n\nexport { CookieConsent, type CookieConsentProps, type ConsentCategory, type ConsentChoice };\n",
      "type": "registry:ui",
      "target": "@components/ballmac/cookie-consent.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "privacy",
      "cookies",
      "consent",
      "gdpr"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "categories[] with {id,label,description,required}. onConsent receives a map of category to boolean. storageKey saves and remembers the choice in localStorage.",
      "whenToUse": [
        "Sites that set non-essential cookies",
        "Consent prompts for analytics and marketing"
      ],
      "whenNotToUse": [
        "Legal advice: check your own requirements",
        "Unrelated notices; use banner"
      ],
      "composesWith": [
        "banner",
        "switch",
        "dialog"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Buttons and switches are all reachable; the banner never traps focus"
        },
        {
          "keys": "Focus",
          "action": "Opening preferences moves focus to the new heading"
        },
        {
          "keys": "Screen readers",
          "action": "A labelled non-modal dialog; switches announce their state"
        }
      ],
      "customization": [
        "categories",
        "placement",
        "policyHref",
        "storageKey or controlled open"
      ]
    },
    "examples": [
      "cookie-consent-demo",
      "cookie-consent-states"
    ],
    "url": "https://ui.ballmac.com/components/cookie-consent"
  },
  "categories": [
    "saas"
  ],
  "type": "registry:ui"
}