{ "$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;\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;\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 \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 \n {/* eslint-disable-next-line @next/next/no-img-element */}\n \n \n \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(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
\n {images.map((image) => (\n \n {/* eslint-disable-next-line @next/next/no-img-element */}\n \n
\n ))}\n \n );\n }\n\n return (\n
\n
\n \n {hint}\n \n
\n\n \n
\n {images.map((image, index) => (\n \n ))}\n
\n
\n \n );\n}\n\nexport default ScrollImageTunnel;\n", "type": "registry:ui", "target": "components/ruixen/scroll-image-tunnel.tsx" } ] }