{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pdp-gallery",
  "type": "registry:component",
  "title": "Product gallery",
  "description": "Crossfading main image, thumbnail strip, 2x hover lens and arrow key navigation.",
  "author": "StoreKit <hello@storekit.dev>",
  "categories": [
    "pdp",
    "any"
  ],
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://storekit-eight.vercel.app/r/cn.json",
    "https://storekit-eight.vercel.app/r/product-image.json"
  ],
  "files": [
    {
      "path": "pdp/pdp-gallery.tsx",
      "content": "\"use client\"\n\nimport { useCallback, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { ChevronLeft, ChevronRight } from \"lucide-react\"\nimport { cn } from \"@/components/storekit/lib/cn\"\nimport { ProductImage } from \"@/components/storekit/ui/product-image\"\n\n/* Product gallery. Main image crossfades between shots, thumbnails run\n   vertically on desktop and horizontally on mobile, a 2x lens follows the\n   cursor on fine pointers, and the arrow keys cycle images. */\n\ntype GalleryImage = { src: string; alt: string }\n\nconst RING = \"outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper\"\n\nconst DEFAULT_IMAGES: GalleryImage[] = [\n  { src: \"/brands/morrow/greens.webp\", alt: \"Daily Greens pouch, front\" },\n  { src: \"/brands/morrow/hero.webp\", alt: \"Morrow daily lineup\" },\n  { src: \"/brands/morrow/electrolytes.webp\", alt: \"Electrolytes pouch\" },\n  { src: \"/brands/morrow/magnesium.webp\", alt: \"Magnesium Night gummies\" },\n]\n\nexport function PdpGallery({\n  images = DEFAULT_IMAGES,\n  className,\n}: {\n  images?: GalleryImage[]\n  className?: string\n}) {\n  const [index, setIndex] = useState(0)\n  const [lens, setLens] = useState<{ x: number; y: number } | null>(null)\n  const reduce = useReducedMotion()\n  const thumbs = useRef<(HTMLButtonElement | null)[]>([])\n  const count = images.length\n  const current = images[Math.min(index, count - 1)]\n\n  const go = useCallback(\n    (next: number) => {\n      const i = (next + count) % count\n      setIndex(i)\n      thumbs.current[i]?.scrollIntoView({ block: \"nearest\", inline: \"nearest\", behavior: reduce ? \"auto\" : \"smooth\" })\n    },\n    [count, reduce]\n  )\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") {\n      e.preventDefault()\n      go(index + 1)\n    } else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") {\n      e.preventDefault()\n      go(index - 1)\n    }\n  }\n\n  const onMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (e.pointerType !== \"mouse\") return\n    const r = e.currentTarget.getBoundingClientRect()\n    setLens({\n      x: Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100)),\n      y: Math.max(0, Math.min(100, ((e.clientY - r.top) / r.height) * 100)),\n    })\n  }\n\n  if (!current) return null\n\n  return (\n    <div\n      role=\"region\"\n      aria-roledescription=\"carousel\"\n      aria-label=\"Product images\"\n      tabIndex={0}\n      onKeyDown={onKeyDown}\n      data-sk-section=\"pdp\"\n      data-sk-variant=\"pdp-gallery\"\n      className={cn(\"flex w-full flex-col-reverse gap-3 self-start rounded-sk-card md:flex-row md:items-start\", RING, className)}\n    >\n      <div className=\"flex gap-2 overflow-x-auto p-1 md:max-h-[520px] md:w-20 md:shrink-0 md:flex-col md:overflow-y-auto md:overflow-x-visible\">\n        {images.map((img, i) => {\n          const active = i === index\n          return (\n            <button\n              key={img.src + i}\n              ref={(el) => {\n                thumbs.current[i] = el\n              }}\n              type=\"button\"\n              aria-label={`Show image ${i + 1} of ${count}`}\n              aria-current={active || undefined}\n              onClick={() => go(i)}\n              className={cn(\n                \"relative h-16 w-16 shrink-0 overflow-hidden rounded-sk-card border bg-sk-surface transition-[border-color,opacity,box-shadow] duration-200 ease-sk-out md:h-[72px] md:w-full\",\n                active ? \"border-sk-accent ring-2 ring-sk-accent/30\" : \"border-sk-line opacity-70 hover:opacity-100\",\n                RING\n              )}\n            >\n              <img src={img.src} alt=\"\" loading=\"lazy\" className=\"absolute inset-0 h-full w-full object-cover\" />\n            </button>\n          )\n        })}\n      </div>\n\n      <div\n        className=\"group/main relative min-w-0 flex-1 overflow-hidden rounded-sk-card border border-sk-line bg-sk-surface md:cursor-zoom-in\"\n        onPointerMove={onMove}\n        onPointerLeave={() => setLens(null)}\n      >\n        <div className=\"relative aspect-square\">\n          <AnimatePresence initial={false}>\n            <motion.div\n              key={current.src + index}\n              className=\"absolute inset-0\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: reduce ? 0 : 0.35, ease: [0.23, 1, 0.32, 1] }}\n            >\n              <ProductImage src={current.src} alt={current.alt} fit=\"cover\" zoomOnHover={false} className=\"h-full w-full\" />\n            </motion.div>\n          </AnimatePresence>\n\n          {lens ? (\n            <div\n              aria-hidden\n              className=\"pointer-events-none absolute inset-0 hidden bg-sk-surface bg-no-repeat md:block\"\n              style={{\n                backgroundImage: `url(${current.src})`,\n                backgroundSize: \"cover\",\n                transform: \"scale(2)\",\n                transformOrigin: `${lens.x}% ${lens.y}%`,\n                backgroundPosition: \"center\",\n              }}\n            />\n          ) : null}\n        </div>\n\n        {count > 1 ? (\n          <div className=\"absolute inset-x-3 bottom-3 flex items-center justify-between\">\n            <button\n              type=\"button\"\n              aria-label=\"Previous image\"\n              onClick={() => go(index - 1)}\n              className={cn(\"grid h-11 w-11 place-items-center rounded-full border border-sk-line bg-sk-card text-sk-ink transition-opacity duration-200 hover:bg-sk-surface active:scale-[0.97] md:opacity-0 md:group-hover/main:opacity-100 md:focus-visible:opacity-100\", RING)}\n            >\n              <ChevronLeft size={18} />\n            </button>\n            <span className=\"rounded-sk-chip border border-sk-line bg-sk-card px-2 py-0.5 font-sk-mono text-[11px] tabular-nums text-sk-muted\">\n              {index + 1} / {count}\n            </span>\n            <button\n              type=\"button\"\n              aria-label=\"Next image\"\n              onClick={() => go(index + 1)}\n              className={cn(\"grid h-11 w-11 place-items-center rounded-full border border-sk-line bg-sk-card text-sk-ink transition-opacity duration-200 hover:bg-sk-surface active:scale-[0.97] md:opacity-0 md:group-hover/main:opacity-100 md:focus-visible:opacity-100\", RING)}\n            >\n              <ChevronRight size={18} />\n            </button>\n          </div>\n        ) : null}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/storekit/pdp/pdp-gallery.tsx"
    }
  ],
  "meta": {
    "section": "pdp",
    "variant": "gallery",
    "niche": [
      "any"
    ],
    "commerce": [
      "media"
    ],
    "previewHeight": 620
  }
}