{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "depth-gallery",
  "type": "registry:ui",
  "title": "Depth Gallery",
  "description": "Cards on a loose spiral at increasing depth that fly toward the viewer as you scroll — a perspective tunnel.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/depth-gallery.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useScroll } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface DepthGalleryItem {\n  id: string;\n  content: React.ReactNode;\n  /** Short title used in announcements (\"Item 3 of 8: title\") and item names. */\n  label: string;\n}\n\nexport interface DepthGalleryProps extends Omit<React.ComponentPropsWithoutRef<\"section\">, \"children\"> {\n  items: DepthGalleryItem[];\n  /** Scrollable ancestor that drives the gallery (make it `position: relative`). Defaults to the window. */\n  container?: React.RefObject<HTMLElement | null>;\n  /** Accessible name of the gallery. */\n  label?: string;\n  /** Card width in px. */\n  cardWidth?: number;\n  /** Card height in px. Defaults to 1.25 × cardWidth. */\n  cardHeight?: number;\n  /** Distance between neighbouring items along the Z axis, in px. */\n  depthGap?: number;\n  /** Radius of the loose spiral the items sit on, in px. 0 lines them up. */\n  spread?: number;\n  /** CSS perspective in px — lower is more dramatic. */\n  perspective?: number;\n  /** Scroll distance that moves the camera from one item to the next, in px. */\n  scrollPerItem?: number;\n  /** Previous / next buttons and the counter. */\n  showControls?: boolean;\n  /** Focused-card edge, focus and control colour (any CSS colour). */\n  accent?: string;\n}\n\nconst GOLDEN_ANGLE = (137.5 * Math.PI) / 180;\nconst r2 = (n: number) => Math.round(n * 100) / 100;\nconst clamp = (n: number, min: number, max: number) => (n < min ? min : n > max ? max : n);\n\n/** Spiral offset of item i in px (golden-angle spiral, flattened vertically). */\nfunction spiral(i: number, spread: number): [number, number] {\n  if (i === 0 || spread === 0) return [0, 0];\n  const a = i * GOLDEN_ANGLE;\n  return [Math.cos(a) * spread, Math.sin(a) * spread * 0.7];\n}\n\n/**\n * Where item i sits when the camera is at `t` (in item units, 0 = first item in focus).\n * The camera pans part of the way toward the spiral offset of the focused item, so cards drift\n * past the viewer rather than lining up dead centre.\n */\nfunction place(i: number, t: number, depthGap: number, spread: number, perspective: number) {\n  const lo = Math.floor(t);\n  const f = t - lo;\n  const [ax, ay] = spiral(lo, spread);\n  const [bx, by] = spiral(lo + 1, spread);\n  const camX = (ax + (bx - ax) * f) * 0.7;\n  const camY = (ay + (by - ay) * f) * 0.7;\n  const [x, y] = spiral(i, spread);\n  const rel = i - t; // > 0: ahead of the camera (farther), < 0: passed\n  const z = -rel * depthGap;\n  // Fade out as a card flies past the viewer, and into the fog when far away.\n  const near = clamp(1 + rel / 0.6, 0, 1);\n  const far = clamp((5 - rel) / 2, 0, 1);\n  const tooClose = z > perspective * 0.8 ? 0 : 1;\n  const opacity = r2(near * far * tooClose);\n  return {\n    transform: `translate3d(${r2(x - camX)}px, ${r2(y - camY)}px, ${r2(z)}px)`,\n    opacity,\n    visibility: opacity < 0.02 ? (\"hidden\" as const) : (\"visible\" as const),\n  };\n}\n\nconst iconButton =\n  \"grid size-9 place-items-center rounded-full border border-zinc-900/12 bg-white/90 text-zinc-700 outline-hidden backdrop-blur-sm transition-colors hover:border-zinc-900/25 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--dg-accent)] aria-disabled:cursor-default aria-disabled:opacity-40 dark:border-white/12 dark:bg-zinc-900/90 dark:text-zinc-300 dark:hover:border-white/25 dark:hover:text-white\";\n\nfunction Arrow({ dir }: { dir: \"back\" | \"forward\" }) {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path d={dir === \"back\" ? \"M10 3.5 5.5 8l4.5 4.5\" : \"M6 3.5 10.5 8 6 12.5\"} />\n    </svg>\n  );\n}\n\n/**\n * A perspective tunnel: cards sit on a loose spiral at increasing depth and fly toward the viewer\n * as the page (or a scrollable container) scrolls. A tall track supplies the scroll distance and\n * a sticky viewport holds the 3D stage; transforms are written straight to the DOM from the\n * scroll progress, so scrolling never re-renders React. With reduced motion it is a plain list.\n */\nexport function DepthGallery({\n  items,\n  container,\n  label = \"Gallery\",\n  cardWidth = 240,\n  cardHeight: cardHeightProp,\n  depthGap = 420,\n  spread = 220,\n  perspective = 900,\n  scrollPerItem = 360,\n  showControls = true,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: DepthGalleryProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const n = items.length;\n  const cardHeight = cardHeightProp ?? Math.round(cardWidth * 1.25);\n  const rootRef = React.useRef<HTMLElement>(null);\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const itemRefs = React.useRef<(HTMLLIElement | null)[]>([]);\n  const indexRef = React.useRef(0);\n  const [index, setIndex] = React.useState(0);\n  const { scrollYProgress } = useScroll({ target: trackRef, container, offset: [\"start start\", \"end end\"] });\n\n  const current = n ? Math.min(index, n - 1) : 0;\n\n  // The sticky viewport is exactly one scroller-height tall.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root || reduceMotion) return;\n    const scroller = container?.current ?? null;\n    const measure = () => {\n      const h = scroller ? scroller.clientHeight : window.innerHeight;\n      root.style.setProperty(\"--dg-view\", `${h}px`);\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    if (scroller) ro.observe(scroller);\n    window.addEventListener(\"resize\", measure);\n    return () => {\n      ro.disconnect();\n      window.removeEventListener(\"resize\", measure);\n    };\n  }, [container, reduceMotion]);\n\n  // Scroll progress → camera position → DOM writes for every card.\n  React.useEffect(() => {\n    if (reduceMotion || !n) return;\n    const update = (p: number) => {\n      const t = clamp(p, 0, 1) * (n - 1);\n      itemRefs.current.forEach((el, i) => {\n        if (!el) return;\n        const s = place(i, t, depthGap, spread, perspective);\n        el.style.transform = s.transform;\n        el.style.opacity = String(s.opacity);\n        el.style.visibility = s.visibility;\n      });\n      const idx = Math.round(t);\n      if (idx !== indexRef.current) {\n        indexRef.current = idx;\n        setIndex(idx);\n      }\n    };\n    update(scrollYProgress.get());\n    return scrollYProgress.on(\"change\", update);\n  }, [scrollYProgress, n, depthGap, spread, perspective, reduceMotion]);\n\n  /** Scrolls so that item k is in focus. */\n  const goTo = (k: number) => {\n    const track = trackRef.current;\n    if (!track) return;\n    const target = clamp(k, 0, n - 1);\n    const scroller = container?.current ?? null;\n    const rect = track.getBoundingClientRect();\n    const behavior: ScrollBehavior = reduceMotion ? \"auto\" : \"smooth\";\n    if (scroller) {\n      const box = scroller.getBoundingClientRect();\n      scroller.scrollTo({ top: rect.top - box.top - scroller.clientTop + scroller.scrollTop + target * scrollPerItem, behavior });\n    } else {\n      window.scrollTo({ top: rect.top + window.scrollY + target * scrollPerItem, behavior });\n    }\n  };\n\n  // An empty gallery renders nothing visible, but keeps useScroll's target mounted.\n  if (!n) return <div ref={trackRef} hidden />;\n\n  if (reduceMotion) {\n    return (\n      <section ref={rootRef} aria-label={label} className={`w-full ${className}`} style={{ [\"--dg-accent\" as string]: accent, ...style }} {...props}>\n        {/* useScroll still needs its target mounted, even though nothing is scroll-linked here. */}\n        <div ref={trackRef}>\n          <ol className=\"mx-auto flex flex-col items-center gap-6 py-6\">\n            {items.map((item, i) => (\n              <li\n                key={item.id}\n                aria-label={`${i + 1} of ${n}: ${item.label}`}\n                className=\"max-w-full overflow-hidden rounded-xl border border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900\"\n                style={{ width: cardWidth, height: cardHeight }}\n              >\n                {item.content}\n              </li>\n            ))}\n          </ol>\n        </div>\n      </section>\n    );\n  }\n\n  const item = items[current];\n\n  return (\n    <section\n      ref={rootRef}\n      aria-roledescription=\"carousel\"\n      aria-label={label}\n      className={`relative w-full ${className}`}\n      style={{ [\"--dg-accent\" as string]: accent, [\"--dg-view\" as string]: \"100svh\", ...style }}\n      {...props}\n    >\n      <div ref={trackRef} className=\"relative\" style={{ height: `calc(var(--dg-view) + ${(n - 1) * scrollPerItem}px)` }}>\n        <div className=\"sticky top-0 w-full overflow-hidden\" style={{ height: \"var(--dg-view)\" }}>\n          <div className=\"absolute inset-0 grid place-items-center\">\n            {/* Perspective sits on the cards' direct parent, so translateZ scales them. */}\n            <ol className=\"relative\" style={{ width: cardWidth, height: cardHeight, perspective }}>\n              {items.map((it, i) => {\n                const s = place(i, 0, depthGap, spread, perspective);\n                const focused = i === current;\n                return (\n                  <li\n                    key={it.id}\n                    ref={(el) => {\n                      itemRefs.current[i] = el;\n                    }}\n                    aria-roledescription=\"slide\"\n                    aria-label={`${i + 1} of ${n}: ${it.label}`}\n                    aria-hidden={!focused}\n                    inert={!focused}\n                    className={`absolute inset-0 overflow-hidden rounded-xl border bg-white shadow-[0_30px_60px_-30px_rgba(0,0,0,0.45)] will-change-transform dark:bg-zinc-900 ${\n                      focused ? \"border-[color-mix(in_srgb,var(--dg-accent)_75%,transparent)]\" : \"border-zinc-900/10 dark:border-white/10\"\n                    }`}\n                    style={{ zIndex: n - i, transform: s.transform, opacity: s.opacity, visibility: s.visibility }}\n                  >\n                    {it.content}\n                  </li>\n                );\n              })}\n            </ol>\n          </div>\n\n          {showControls && (\n            <div className=\"absolute inset-x-0 bottom-4 flex items-center justify-center gap-2 px-4\">\n              <button type=\"button\" aria-label=\"Previous item\" aria-disabled={current === 0} onClick={() => current > 0 && goTo(current - 1)} className={iconButton}>\n                <Arrow dir=\"back\" />\n              </button>\n              <span\n                aria-hidden=\"true\"\n                className=\"min-w-[9rem] rounded-full border border-zinc-900/10 bg-white/85 px-3 py-1.5 text-center font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 backdrop-blur-sm dark:border-white/10 dark:bg-zinc-950/80 dark:text-zinc-400\"\n              >\n                <span className=\"text-zinc-900 dark:text-zinc-100\">{String(current + 1).padStart(2, \"0\")}</span> / {String(n).padStart(2, \"0\")} · {item.label}\n              </span>\n              <button type=\"button\" aria-label=\"Next item\" aria-disabled={current === n - 1} onClick={() => current < n - 1 && goTo(current + 1)} className={iconButton}>\n                <Arrow dir=\"forward\" />\n              </button>\n            </div>\n          )}\n        </div>\n      </div>\n\n      <div className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {`Item ${current + 1} of ${n}: ${item.label}`}\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/depth-gallery"
}
