{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "product-gallery",
  "type": "registry:ui",
  "title": "Product Gallery",
  "description": "Product photos that crossfade on pick, swipe on touch and magnify under the mouse.",
  "categories": [
    "media"
  ],
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/use-controllable-state.json",
    "https://www.stealth.pm/r/blur-up-image.json"
  ],
  "files": [
    {
      "path": "components/ui/product-gallery.tsx",
      "content": "\"use client\";\nimport { Tabs } from \"@base-ui/react/tabs\";\nimport { useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\nimport { BlurUpImage } from \"@/components/ui/blur-up-image\";\n\nexport type ProductImage = {\n  src: string;\n  alt: string;\n  /** A small version for the thumbnail strip; also the blurred placeholder while `src` loads. */\n  thumb?: string;\n  /** A larger file for the zoom lens. Defaults to `src`. */\n  zoomSrc?: string;\n};\n\ntype Via = \"click\" | \"key\" | \"swipe\";\n\nexport type ProductGalleryProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\" | \"onChange\"> & {\n  images: ProductImage[];\n  /** The index of the image showing. */\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (index: number) => void;\n  /** Shape of the main image. */\n  aspectRatio?: number | string;\n  /** Where the thumbnails sit. */\n  thumbnails?: \"bottom\" | \"left\";\n  /** Magnification of the hover lens. */\n  zoom?: number;\n  /** Show a magnifying lens under the mouse. Touch devices never get it. */\n  lens?: boolean;\n  /** Accessible name for the thumbnail list. */\n  label?: string;\n};\n\nexport function ProductGallery({\n  images,\n  value: valueProp,\n  defaultValue = 0,\n  onValueChange,\n  aspectRatio = \"1 / 1\",\n  thumbnails = \"bottom\",\n  zoom = 2.5,\n  lens = true,\n  label = \"Product images\",\n  className,\n  ...rest\n}: ProductGalleryProps) {\n  const [index, setIndex] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [via, setVia] = useState<Via>(\"click\");\n  const [dragging, setDragging] = useState(false);\n  const [lensOn, setLensOn] = useState(false);\n  const stage = useRef<HTMLDivElement>(null);\n  const lensEl = useRef<HTMLDivElement>(null);\n  const drag = useRef<{ id: number; x: number; y: number; axis: \"x\" | \"y\" | null; dx: number; t: number; v: number } | null>(null);\n  const count = images.length;\n  const vertical = thumbnails === \"left\";\n\n  const go = (next: number, how: Via) => {\n    if (next < 0 || next >= count || next === index) return;\n    setVia(how);\n    setIndex(next);\n  };\n\n  // --- Swipe on touch: the photo follows the finger with its neighbour peeking in beside it. ---\n  const setDrag = (px: number) => stage.current?.style.setProperty(\"--pg-drag\", `${px}px`);\n  const onPointerDown = (e: React.PointerEvent) => {\n    if (e.pointerType === \"mouse\" || count < 2) return;\n    drag.current = { id: e.pointerId, x: e.clientX, y: e.clientY, axis: null, dx: 0, t: e.timeStamp, v: 0 };\n  };\n  const onPointerMove = (e: React.PointerEvent) => {\n    const d = drag.current;\n    if (!d || d.id !== e.pointerId) return;\n    const dx = e.clientX - d.x;\n    const dy = e.clientY - d.y;\n    if (!d.axis) {\n      if (Math.hypot(dx, dy) < 6) return;\n      d.axis = Math.abs(dx) > Math.abs(dy) ? \"x\" : \"y\";\n      if (d.axis === \"y\") return;\n      // Capture can fail if the pointer already ended; the drag still works without it.\n      try {\n        stage.current?.setPointerCapture(e.pointerId);\n      } catch {}\n      setDragging(true);\n    }\n    if (d.axis !== \"x\") return;\n    const w = stage.current?.clientWidth ?? 1;\n    const atEdge = (dx > 0 && index === 0) || (dx < 0 && index === count - 1);\n    // Past the first or last photo it gives a little and no more.\n    const moved = atEdge ? (Math.sign(dx) * (Math.abs(dx) * w * 0.55)) / (w + 0.55 * Math.abs(dx)) : dx;\n    d.v = ((moved - d.dx) / Math.max(1, e.timeStamp - d.t)) * 1000;\n    d.dx = moved;\n    d.t = e.timeStamp;\n    setDrag(moved);\n  };\n  const onPointerUp = (e: React.PointerEvent) => {\n    const d = drag.current;\n    if (!d || d.id !== e.pointerId) return;\n    drag.current = null;\n    if (d.axis !== \"x\") return;\n    const w = stage.current?.clientWidth ?? 1;\n    const dir = d.dx < 0 ? 1 : -1;\n    const next = index + dir;\n    const commit = e.type === \"pointerup\" && next >= 0 && next < count && (Math.abs(d.dx) > w * 0.2 || (Math.abs(d.v) > 400 && Math.sign(d.v) === -dir));\n    // The strip carries on from wherever the finger left it: to the next photo, or back.\n    if (commit) go(next, \"swipe\");\n    setDrag(0);\n    setDragging(false);\n  };\n\n  // --- Hover lens: a loupe that follows the mouse 1:1 over the photo. ---\n  const moveLens = (e: React.PointerEvent) => {\n    const s = stage.current;\n    const l = lensEl.current;\n    if (!s || !l) return;\n    const r = s.getBoundingClientRect();\n    const img = s.querySelector<HTMLImageElement>(\"[data-pg-active] img:last-of-type\");\n    const nw = img?.naturalWidth || r.width;\n    const nh = img?.naturalHeight || r.height;\n    // The photo covers the stage, so it may overflow one axis; work in its rendered box.\n    const k = Math.max(r.width / nw, r.height / nh);\n    const iw = nw * k;\n    const ih = nh * k;\n    const ox = (r.width - iw) / 2;\n    const oy = (r.height - ih) / 2;\n    const x = Math.min(Math.max(e.clientX - r.left, 0), r.width);\n    const y = Math.min(Math.max(e.clientY - r.top, 0), r.height);\n    const size = l.offsetWidth;\n    l.style.translate = `${x - size / 2}px ${y - size / 2}px`;\n    l.style.backgroundSize = `${iw * zoom}px ${ih * zoom}px`;\n    l.style.backgroundPosition = `${-((x - ox) * zoom - size / 2)}px ${-((y - oy) * zoom - size / 2)}px`;\n  };\n\n  const active = images[index];\n\n  return (\n    <Tabs.Root\n      value={index}\n      onValueChange={(v, details) => go(v as number, details.event?.type === \"keydown\" ? \"key\" : \"click\")}\n      orientation={vertical ? \"vertical\" : \"horizontal\"}\n      data-via={via}\n      data-mode={dragging || via === \"swipe\" ? \"track\" : \"fade\"}\n      className={cn(\"group/pg flex w-full gap-2.5\", vertical ? \"flex-row-reverse items-start\" : \"flex-col\", className)}\n      {...rest}\n    >\n      <div\n        ref={stage}\n        data-dragging={dragging || undefined}\n        data-lens={lensOn || undefined}\n        onPointerDown={onPointerDown}\n        onPointerMove={(e) => {\n          onPointerMove(e);\n          if (lensOn) moveLens(e);\n        }}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        onPointerEnter={(e) => {\n          if (!lens || e.pointerType !== \"mouse\") return;\n          setLensOn(true);\n          moveLens(e);\n        }}\n        onPointerLeave={() => setLensOn(false)}\n        className={cn(\n          \"relative isolate grid min-w-0 flex-1 touch-pan-y select-none overflow-hidden rounded-xl bg-hover [--pg-drag:0px]\",\n          // The photo clips its own outline, so a focused photo rings the frame instead.\n          \"outline-offset-2 outline-fg-3 has-[[role=tabpanel]:focus-visible]:outline-1 has-[[role=tabpanel]:focus-visible]:outline-solid\",\n          lens && \"data-lens:cursor-none\",\n        )}\n        style={{ aspectRatio }}\n      >\n        {images.map((img, i) => (\n          <Tabs.Panel\n            key={i}\n            value={i}\n            // Every photo stays mounted and laid out (hidden from assistive tech and focus instead of\n            // display: none), so the next one is already decoded when it's chosen.\n            keepMounted\n            render={(props, state) => <div {...props} hidden={undefined} aria-hidden={state.hidden || undefined} inert={state.hidden || undefined} />}\n            data-pg-active={i === index || undefined}\n            aria-label={`${i + 1} of ${count}: ${img.alt}`}\n            className={cn(\n              \"col-start-1 row-start-1 outline-none\",\n              // Choosing a thumbnail: the new photo fades in over the old one and settles from a touch\n              // larger; the old one stays solid underneath until it's covered, so the two never dip together.\n              \"group-data-[mode=fade]/pg:transition-[opacity,scale] group-data-[mode=fade]/pg:duration-300 ease-out-expo\",\n              \"group-data-[mode=fade]/pg:data-pg-active:z-1\",\n              \"group-data-[mode=fade]/pg:not-data-pg-active:scale-[1.04] group-data-[mode=fade]/pg:not-data-pg-active:opacity-0 group-data-[mode=fade]/pg:not-data-pg-active:delay-300 group-data-[mode=fade]/pg:not-data-pg-active:duration-0\",\n              // Swiping: the photos sit side by side in a strip that follows the finger.\n              \"group-data-[mode=track]/pg:translate-x-[calc(var(--pg-offset)*(100%+12px)+var(--pg-drag))]\",\n              \"group-data-[mode=track]/pg:transition-[translate] group-data-[mode=track]/pg:duration-[380ms] group-data-[mode=track]/pg:ease-out-quart\",\n              \"group-data-dragging/pg:transition-none! group-data-[via=key]/pg:transition-none!\",\n              \"motion-reduce:scale-100! motion-reduce:transition-opacity\",\n            )}\n            style={{ \"--pg-offset\": i - index } as React.CSSProperties}\n          >\n            <BlurUpImage\n              src={img.src}\n              placeholder={img.thumb}\n              alt={img.alt}\n              priority={i === defaultValue}\n              aspectRatio={aspectRatio}\n              className=\"size-full\"\n            />\n          </Tabs.Panel>\n        ))}\n\n        {lens && (\n          <div\n            ref={lensEl}\n            aria-hidden\n            className={cn(\n              \"pointer-events-none absolute left-0 top-0 z-10 size-[clamp(96px,38%,168px)] rounded-full bg-raised bg-no-repeat shadow-pop ring-2 ring-raised dark:ring-fg/80\",\n              \"opacity-0 transition-[opacity,scale] duration-150 ease-out-expo [scale:0.85]\",\n              \"group-data-dragging/pg:hidden data-[show]:opacity-100 data-[show]:[scale:1] motion-reduce:[scale:1]\",\n            )}\n            data-show={lensOn || undefined}\n            style={{ backgroundImage: `url(\"${active.zoomSrc ?? active.src}\")` }}\n          />\n        )}\n\n        {count > 1 && (\n          <span aria-hidden className=\"pointer-events-none absolute bottom-2.5 left-2.5 z-10 rounded-full bg-page/70 px-2 py-0.5 font-mono text-[11px] text-fg-2 backdrop-blur-sm tabular pointer-fine:hidden\">\n            {index + 1}/{count}\n          </span>\n        )}\n      </div>\n\n      {count > 1 && (\n        <Tabs.List\n          activateOnFocus\n          aria-label={label}\n          className={cn(\n            \"relative flex shrink-0 gap-2 p-1 [scrollbar-width:none]\",\n            vertical ? \"max-h-full flex-col overflow-y-auto\" : \"-m-1 overflow-x-auto\",\n          )}\n        >\n          {images.map((img, i) => (\n            <Tabs.Tab\n              key={i}\n              value={i}\n              aria-label={`Image ${i + 1} of ${count}: ${img.alt}`}\n              className={cn(\n                \"group/thumb relative size-14 shrink-0 overflow-hidden rounded-lg bg-hover sm:size-16\",\n                \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-[5px] focus-visible:outline-fg-3\",\n                \"transition-[opacity,scale] duration-150 ease-out active:scale-[0.95] active:duration-75\",\n                \"opacity-60 hover:opacity-90 data-active:opacity-100\",\n              )}\n            >\n              {/* eslint-disable-next-line @next/next/no-img-element */}\n              <img src={img.thumb ?? img.src} alt=\"\" draggable={false} loading=\"lazy\" className=\"size-full object-cover\" />\n            </Tabs.Tab>\n          ))}\n          {/* One ring for the whole strip, sliding to the chosen thumbnail. */}\n          <Tabs.Indicator\n            className={cn(\n              \"pointer-events-none absolute left-0 top-0 z-10 rounded-[10px]\",\n              \"h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) translate-y-(--active-tab-top)\",\n              \"shadow-[0_0_0_2px_var(--frame),0_0_0_3.5px_var(--fg)]\",\n              \"transition-[translate,width,height] duration-[260ms] ease-in-out-quart group-data-[via=key]/pg:transition-none motion-reduce:transition-none\",\n            )}\n          />\n        </Tabs.List>\n      )}\n    </Tabs.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/product-gallery"
}
