{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "age-gate",
  "type": "registry:component",
  "title": "Age gate",
  "description": "Full-screen 18+ interstitial that fades out on confirm and remembers the answer for 30 days.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "compliance",
    "any"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/sk-analytics.json"
  ],
  "files": [
    {
      "path": "compliance/age-gate.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { cn } from \"@/components/storekit/lib/cn\"\nimport { useSkBlock } from \"@/components/storekit/lib/sk-analytics\"\n\ntype Sk = ReturnType<typeof useSkBlock>\n\n/** Mounts with the gate so the view event fires when it actually appears. */\nfunction Tracked({ children }: { children: (sk: Sk) => React.ReactNode }) {\n  const sk = useSkBlock(\"compliance\", \"age-gate\")\n  return <>{children(sk)}</>\n}\n\n/* Full-screen age interstitial. A modal dialog with scroll lock and a focus\n   trap; there is no Escape or backdrop exit by design. Confirming fades it out\n   and stores an expiry in localStorage for `rememberDays`. Declining shows a\n   blocked state with a way back. Set `contained` to cover only the nearest\n   relative parent for previews; it then skips the page scroll lock, initial\n   focus, and focus trap so the surrounding page stays usable. */\n\nconst ring =\n  \"outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper\"\n\nexport function AgeGate({\n  brandName = \"Morrow\",\n  markSrc,\n  minAge = 18,\n  message = \"Some products on this site are only for adults. Confirm your age to continue.\",\n  rememberDays = 30,\n  storageKey = \"sk-age-verified\",\n  contained = false,\n  onConfirm,\n  className,\n}: {\n  brandName?: string\n  markSrc?: string\n  minAge?: number\n  /** One line under the heading explaining why you ask. */\n  message?: string\n  rememberDays?: number\n  storageKey?: string\n  contained?: boolean\n  onConfirm?: () => void\n  className?: string\n}) {\n  const reduce = useReducedMotion()\n  const [open, setOpen] = useState(false)\n  const [denied, setDenied] = useState(false)\n  const ref = useRef<HTMLDivElement>(null)\n  const titleId = useId()\n  const descId = useId()\n\n  useEffect(() => {\n    let valid = false\n    try {\n      const until = Number(window.localStorage.getItem(storageKey))\n      valid = Number.isFinite(until) && until > Date.now()\n    } catch {}\n    setOpen(!valid)\n  }, [storageKey])\n\n  useEffect(() => {\n    if (!open || contained) return\n    const prev = document.body.style.overflow\n    document.body.style.overflow = \"hidden\"\n    const raf = requestAnimationFrame(() => ref.current?.querySelector<HTMLElement>(\"button\")?.focus())\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Tab\" || !ref.current) return\n      const items = Array.from(ref.current.querySelectorAll<HTMLElement>(\"button:not([disabled]),a[href]\"))\n      if (!items.length) return\n      const first = items[0]\n      const last = items[items.length - 1]\n      if (!ref.current.contains(document.activeElement)) {\n        e.preventDefault()\n        first.focus()\n      } else if (e.shiftKey && document.activeElement === first) {\n        e.preventDefault()\n        last.focus()\n      } else if (!e.shiftKey && document.activeElement === last) {\n        e.preventDefault()\n        first.focus()\n      }\n    }\n    document.addEventListener(\"keydown\", onKey)\n    return () => {\n      cancelAnimationFrame(raf)\n      document.body.style.overflow = prev\n      document.removeEventListener(\"keydown\", onKey)\n    }\n  }, [open, denied, contained])\n\n  const confirm = (sk: Sk) => {\n    sk.emit(\"convert\", \"age_confirm\")\n    try {\n      window.localStorage.setItem(storageKey, String(Date.now() + rememberDays * 86400000))\n    } catch {}\n    setOpen(false)\n    onConfirm?.()\n  }\n\n  return (\n    <AnimatePresence>\n      {open ? (\n        <Tracked key=\"gate\">\n          {(sk) => (\n            <motion.div\n              ref={(el) => {\n                ref.current = el\n                sk.ref.current = el\n              }}\n              {...sk.attrs}\n              role=\"dialog\"\n              aria-modal={contained ? undefined : true}\n              aria-labelledby={titleId}\n              aria-describedby={descId}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}\n              className={cn(contained ? \"absolute\" : \"fixed\", \"inset-0 z-50 grid place-items-center bg-sk-paper px-4\", className)}\n            >\n              <motion.div\n                initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, y: reduce ? 0 : -8 }}\n                transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.05 }}\n                className=\"flex w-full max-w-sm flex-col items-center text-center\"\n              >\n                {markSrc ? <img src={markSrc} alt=\"\" className=\"h-10 w-10 object-contain\" /> : null}\n                <p className=\"mt-4 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted\">{brandName}</p>\n                <AnimatePresence mode=\"wait\" initial={false}>\n                  {denied ? (\n                    <motion.div\n                      key=\"denied\"\n                      initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n                      animate={{ opacity: 1, y: 0 }}\n                      exit={{ opacity: 0 }}\n                      transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}\n                      className=\"flex flex-col items-center\"\n                    >\n                      <h2 id={titleId} className=\"mt-2 font-sk-display text-2xl font-semibold leading-[1.1] tracking-[-0.02em] text-balance\">\n                        Access restricted\n                      </h2>\n                      <p id={descId} className=\"mt-3 text-sm leading-[1.5] text-sk-muted\">\n                        This store is only available to adults {minAge} and older.\n                      </p>\n                      <button\n                        type=\"button\"\n                        onClick={() => setDenied(false)}\n                        className={cn(\"mt-6 inline-flex min-h-11 items-center rounded-sk-btn px-3 text-sm text-sk-ink underline underline-offset-4\", ring)}\n                      >\n                        I answered by mistake\n                      </button>\n                    </motion.div>\n                  ) : (\n                    <motion.div\n                      key=\"ask\"\n                      initial={{ opacity: 0, y: reduce ? 0 : 8 }}\n                      animate={{ opacity: 1, y: 0 }}\n                      exit={{ opacity: 0 }}\n                      transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}\n                      className=\"flex w-full flex-col items-center\"\n                    >\n                      <h2 id={titleId} className=\"mt-2 font-sk-display text-2xl font-semibold leading-[1.1] tracking-[-0.02em] text-balance\">\n                        You must be {minAge} or older\n                      </h2>\n                      <p id={descId} className=\"mt-3 text-sm leading-[1.5] text-sk-muted\">\n                        {message}\n                      </p>\n                      <div className=\"mt-6 grid w-full gap-2 sm:grid-cols-2\">\n                        <button\n                          type=\"button\"\n                          onClick={() => confirm(sk)}\n                          className={cn(\"inline-flex h-11 items-center justify-center rounded-sk-btn bg-sk-accent px-5 text-sm font-medium text-sk-accent-ink transition-[transform,filter] duration-200 hover:brightness-[1.06] active:scale-[0.97]\", ring)}\n                        >\n                          I am {minAge} or older\n                        </button>\n                        <button\n                          type=\"button\"\n                          onClick={() => setDenied(true)}\n                          className={cn(\"inline-flex h-11 items-center justify-center rounded-sk-btn border border-sk-line px-5 text-sm font-medium text-sk-ink transition-[transform,background-color] duration-200 hover:bg-sk-ink/[0.04] active:scale-[0.97]\", ring)}\n                        >\n                          I am under {minAge}\n                        </button>\n                      </div>\n                      <p className=\"mt-4 text-xs text-sk-muted\">We remember your answer for {rememberDays} days.</p>\n                    </motion.div>\n                  )}\n                </AnimatePresence>\n              </motion.div>\n            </motion.div>\n          )}\n        </Tracked>\n      ) : null}\n    </AnimatePresence>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/compliance/age-gate.tsx"
    }
  ],
  "docs": "Mount once in your root layout. contained is for previews and skips the page scroll lock and focus trap.",
  "meta": {
    "section": "compliance",
    "variant": "age-gate",
    "niche": [
      "any"
    ],
    "commerce": [
      "age-verification"
    ],
    "previewHeight": 500
  }
}