{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-image-tunnel",
  "type": "registry:ui",
  "title": "Scroll Image Tunnel",
  "description": "A pinned photo stage where each image grows from a small point in the center, developing from an oversaturated, high-contrast state into the true image as it settles, cycling through the set as the page scrolls.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/ruixenui/scroll-image-tunnel.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  motion,\n  useMotionTemplate,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ScrollImageTunnelImage {\n  /** Image URL. */\n  src: string;\n  /** Alt text. */\n  alt: string;\n}\n\nexport interface ScrollImageTunnelProps {\n  /** Photos shown in sequence, one per scroll segment. */\n  images: ScrollImageTunnelImage[];\n  /** Hint shown above the pinned stage before the user starts scrolling. */\n  hint?: React.ReactNode;\n  /** Scroll distance dedicated to each photo (taller = slower reveal). Default `\"200vh\"`. */\n  stepHeight?: string;\n  /**\n   * Scrollable ancestor to track instead of the page — pass this when pinning\n   * inside a bounded panel (e.g. a preview container) rather than the window.\n   */\n  container?: React.RefObject<HTMLElement | null>;\n  className?: string;\n}\n\nfunction TunnelFrame({\n  src,\n  alt,\n  index,\n  total,\n  progress,\n}: {\n  src: string;\n  alt: string;\n  index: number;\n  total: number;\n  progress: MotionValue<number>;\n}) {\n  const local = useTransform(\n    progress,\n    [index / total, (index + 1) / total],\n    [0, 1],\n  );\n  // Each photo starts as a small point in the middle of the frame and scales\n  // up until it fully covers it. Never blurred: it starts punchy — oversaturated,\n  // overcontrasted, \"unclear\" the way an overdeveloped print is unclear — and\n  // settles into the true, correctly graded image as it finishes growing.\n  // Opacity stays at 0 until its own turn begins, so frames waiting their\n  // turn stay fully hidden instead of lingering as a stray speck.\n  const scale = useTransform(local, [0, 0.8], [0.05, 1]);\n  const y = useTransform(local, [0, 0.75], [40, 0]);\n  const opacity = useTransform(local, [0, 0.03, 1], [0, 1, 1]);\n  const contrast = useTransform(local, [0, 0.7], [2.2, 1]);\n  const saturate = useTransform(local, [0, 0.7], [2.6, 1]);\n  const filter = useMotionTemplate`contrast(${contrast}) saturate(${saturate})`;\n\n  return (\n    <div\n      style={{ zIndex: index }}\n      className=\"absolute inset-0 flex items-center justify-center\"\n    >\n      {/* Flexbox handles centering so it never fights with the scale/y\n          transform below — motion owns the transform property once a\n          motion value drives it, so a translate-based centering class on\n          the same element would get silently clobbered. */}\n      <motion.div\n        style={{ scale, y, opacity, filter }}\n        className=\"h-[70%] w-full max-w-xl overflow-hidden\"\n      >\n        {/* eslint-disable-next-line @next/next/no-img-element */}\n        <img\n          src={src}\n          alt={alt}\n          loading={index === 0 ? \"eager\" : \"lazy\"}\n          decoding=\"async\"\n          draggable={false}\n          className=\"h-full w-full object-cover\"\n        />\n      </motion.div>\n    </div>\n  );\n}\n\nexport function ScrollImageTunnel({\n  images,\n  hint = \"Scroll down to reveal the images\",\n  stepHeight = \"200vh\",\n  container,\n  className,\n}: ScrollImageTunnelProps) {\n  const prefersReducedMotion = useReducedMotion();\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const progress = useMotionValue(0);\n\n  React.useEffect(() => {\n    if (prefersReducedMotion) return;\n    const el = containerRef.current;\n    if (!el) return;\n    const containerEl = container?.current ?? null;\n    const win = el.ownerDocument.defaultView ?? window;\n    const target: HTMLElement | Window = containerEl ?? win;\n\n    let raf = 0;\n    const update = () => {\n      raf = 0;\n      const rect = el.getBoundingClientRect();\n      const viewport = containerEl ? containerEl.clientHeight : win.innerHeight;\n      const top = containerEl\n        ? rect.top - containerEl.getBoundingClientRect().top\n        : rect.top;\n      const denom = rect.height - viewport || 1;\n      progress.set(Math.min(1, Math.max(0, -top / denom)));\n    };\n    const onScroll = () => {\n      if (!raf) raf = win.requestAnimationFrame(update);\n    };\n\n    update();\n    target.addEventListener(\"scroll\", onScroll, { passive: true });\n    win.addEventListener(\"resize\", onScroll);\n    const ro = containerEl ? new ResizeObserver(onScroll) : null;\n    if (containerEl && ro) ro.observe(containerEl);\n\n    return () => {\n      target.removeEventListener(\"scroll\", onScroll);\n      win.removeEventListener(\"resize\", onScroll);\n      ro?.disconnect();\n      if (raf) win.cancelAnimationFrame(raf);\n    };\n  }, [prefersReducedMotion, progress, container]);\n\n  if (prefersReducedMotion) {\n    return (\n      <div className={cn(\"grid gap-4 bg-muted p-6\", className)}>\n        {images.map((image) => (\n          <div\n            key={image.src}\n            className=\"mx-auto aspect-[3/4] w-full max-w-xl overflow-hidden bg-background\"\n          >\n            {/* eslint-disable-next-line @next/next/no-img-element */}\n            <img\n              src={image.src}\n              alt={image.alt}\n              loading=\"lazy\"\n              className=\"h-full w-full object-cover\"\n            />\n          </div>\n        ))}\n      </div>\n    );\n  }\n\n  return (\n    <div className={cn(\"w-full overflow-clip\", className)}>\n      <div className=\"my-20 grid content-start justify-items-center gap-6 text-center\">\n        <span className=\"relative max-w-[12ch] text-xs uppercase leading-tight text-muted-foreground after:absolute after:left-1/2 after:top-full after:h-16 after:w-px after:bg-gradient-to-b after:from-transparent after:to-muted-foreground/40 after:content-['']\">\n          {hint}\n        </span>\n      </div>\n\n      <div\n        ref={containerRef}\n        style={{ height: `calc(${images.length} * ${stepHeight})` }}\n        className=\"w-full\"\n      >\n        <section className=\"sticky top-0 h-screen w-full overflow-hidden bg-background\">\n          {images.map((image, index) => (\n            <TunnelFrame\n              key={image.src}\n              src={image.src}\n              alt={image.alt}\n              index={index}\n              total={images.length}\n              progress={progress}\n            />\n          ))}\n        </section>\n      </div>\n    </div>\n  );\n}\n\nexport default ScrollImageTunnel;\n",
      "type": "registry:ui",
      "target": "components/ruixen/scroll-image-tunnel.tsx"
    }
  ]
}