{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "scroll-tilted-grid", "type": "registry:ui", "title": "Scroll Tilted Grid", "description": "An editorial two-column image grid where pairs of stills rise from below tipped forward, settle into focus, then tilt away over the top edge as the page scrolls. Optional infinite loop appends more cycles via IntersectionObserver.", "dependencies": [ "motion" ], "registryDependencies": [], "files": [ { "path": "registry/ruixenui/scroll-tilted-grid.tsx", "content": "\"use client\";\n\nimport {\n motion,\n useMotionValue,\n useTransform,\n useMotionTemplate,\n useReducedMotion,\n cubicBezier,\n type MotionValue,\n} from \"motion/react\";\nimport type { RefObject } from \"react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\n/**\n * Curated editorial portrait set used as the default `images` for {@link ScrollTiltedGrid}.\n * Hosted on Pinterest's CDN — fine for demos and prototypes; swap to your own assets in production.\n */\nexport const DEFAULT_GRID_IMAGES: readonly string[] = [\n \"https://i.pinimg.com/736x/de/0f/9c/de0f9c57bf7ae1c48ea467ffe9817fdc.jpg\",\n \"https://i.pinimg.com/736x/80/17/36/8017367dbe52dae63b58a678018795ee.jpg\",\n \"https://i.pinimg.com/736x/0d/b6/1f/0db61f5245c835228df83398f6d96ceb.jpg\",\n \"https://i.pinimg.com/736x/39/27/f5/3927f53cebd0a148ba806fbd15e1fdd9.jpg\",\n \"https://i.pinimg.com/1200x/5f/ae/6d/5fae6de0940fe4a2471f34fb1b259b77.jpg\",\n \"https://i.pinimg.com/736x/df/04/61/df0461286b3e5291300adbffa70b3e9e.jpg\",\n \"https://i.pinimg.com/736x/6d/45/f1/6d45f1c96c3316c3bc5055ed6e8e3b8f.jpg\",\n \"https://i.pinimg.com/736x/a9/4c/e0/a94ce014127cfded1c7160b110eb7a86.jpg\",\n \"https://i.pinimg.com/736x/fe/f0/8a/fef08a661d0ef55561d99a293c79dd81.jpg\",\n \"https://i.pinimg.com/736x/84/c6/10/84c610443c77c1e34398f071fdc3b71a.jpg\",\n \"https://i.pinimg.com/736x/54/13/9d/54139d6fd658b1d5e71cdc07ea37a57c.jpg\",\n \"https://i.pinimg.com/736x/2d/0b/74/2d0b74227b38d56fcc8b9f4872addcfc.jpg\",\n];\n\nconst easeIntoFocus = cubicBezier(0.22, 1, 0.36, 1);\nconst easeOutOfFocus = cubicBezier(0, 0, 0.58, 1);\nconst focusEase: [typeof easeIntoFocus, typeof easeOutOfFocus] = [\n easeIntoFocus,\n easeOutOfFocus,\n];\n\nexport type MaxWidthToken = \"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xl\" | \"3xl\" | \"none\";\n\nexport type GapToken = 4 | 6 | 8 | 10 | 12 | 14;\n\nconst MAX_WIDTH_CLASS: Record = {\n sm: \"max-w-sm\",\n md: \"max-w-md\",\n lg: \"max-w-lg\",\n xl: \"max-w-xl\",\n \"2xl\": \"max-w-2xl\",\n \"3xl\": \"max-w-3xl\",\n none: \"\",\n};\n\nconst GAP_CLASS: Record = {\n 4: \"gap-4\",\n 6: \"gap-6\",\n 8: \"gap-8\",\n 10: \"gap-10\",\n 12: \"gap-12\",\n 14: \"gap-14\",\n};\n\ntype Side = \"L\" | \"R\";\n\ntype TileConfig = {\n aspectRatio: string;\n perspective: number;\n maxTilt: number;\n maxBlur: number;\n rounded: string;\n scrollY: MotionValue;\n container?: RefObject;\n};\n\nfunction Tile({\n src,\n side,\n config,\n}: {\n src: string;\n side: Side;\n config: TileConfig;\n}) {\n const ref = useRef(null);\n const reduce = useReducedMotion();\n const sign = side === \"L\" ? -1 : 1;\n const {\n aspectRatio,\n perspective,\n maxTilt,\n maxBlur,\n rounded,\n scrollY,\n container,\n } = config;\n\n // Per-tile scroll progress, computed from the shared scrollY motion value\n // and this tile's measured position. Driving this manually instead of via\n // useScroll({ target, container }) avoids motion's quirky first-frame\n // behavior with that combo (which would lock every tile at p=0 = entry pose\n // until the first real scroll event fires).\n const p = useMotionValue(0);\n\n useEffect(() => {\n const tile = ref.current;\n if (!tile) return;\n const containerEl = container?.current ?? null;\n\n const compute = () => {\n const tileRect = tile.getBoundingClientRect();\n const containerHeight = containerEl\n ? containerEl.clientHeight\n : window.innerHeight;\n const tileTop = containerEl\n ? tileRect.top - containerEl.getBoundingClientRect().top\n : tileRect.top;\n\n const range = containerHeight + tileRect.height;\n if (range <= 0) {\n p.set(0);\n return;\n }\n const value = (containerHeight - tileTop) / range;\n p.set(Math.max(0, Math.min(1, value)));\n };\n\n compute();\n const unsubscribe = scrollY.on(\"change\", compute);\n\n const ro = new ResizeObserver(compute);\n ro.observe(tile);\n if (containerEl) ro.observe(containerEl);\n\n const onResize = () => compute();\n window.addEventListener(\"resize\", onResize);\n\n return () => {\n unsubscribe();\n ro.disconnect();\n window.removeEventListener(\"resize\", onResize);\n };\n }, [scrollY, container, p]);\n\n const blur = useTransform(p, [0, 0.5, 1], [maxBlur, 0, maxBlur], {\n ease: focusEase,\n });\n const bright = useTransform(p, [0, 0.5, 1], [0, 1, 0], { ease: focusEase });\n const contrast = useTransform(p, [0, 0.5, 1], [4, 1, 4], { ease: focusEase });\n\n const ty = useTransform(p, [0, 0.5, 1], [\"100%\", \"0%\", \"-100%\"], {\n ease: focusEase,\n });\n const tz = useTransform(p, [0, 0.5, 1], [300, 0, 300], { ease: focusEase });\n const rx = useTransform(p, [0, 0.5, 1], [maxTilt, 0, -maxTilt], {\n ease: focusEase,\n });\n\n const tx = useTransform(\n p,\n [0, 0.5, 1],\n [`${sign * 40}%`, \"0%\", `${sign * 40}%`],\n { ease: focusEase },\n );\n const rot = useTransform(p, [0, 0.5, 1], [-sign * 5, 0, sign * 5], {\n ease: focusEase,\n });\n const sk = useTransform(p, [0, 0.5, 1], [sign * 20, 0, -sign * 20], {\n ease: focusEase,\n });\n\n const innerSY = useTransform(p, [0, 0.5, 1], [1.8, 1, 1.8], {\n ease: focusEase,\n });\n\n const filter = useMotionTemplate`blur(${blur}px) brightness(${bright}) contrast(${contrast})`;\n\n if (reduce) {\n return (\n
\n \n \n \n
\n );\n }\n\n return (\n \n \n \n \n \n );\n}\n\nexport type ScrollTiltedGridProps = {\n /** Image URLs to render. Falls back to {@link DEFAULT_GRID_IMAGES}. */\n images?: readonly string[];\n /**\n * Cycle the source list and append more pairs as the user nears the bottom —\n * a perceptually infinite scroll. Default `false`.\n */\n loop?: boolean;\n /** Initial number of cycles to render when `loop` is on. Default `3`. */\n initialCycles?: number;\n /** CSS `aspect-ratio` value for each tile, e.g. `\"3/4\"`, `\"2/3\"`. Default `\"3/4\"`. */\n aspectRatio?: string;\n /** Tailwind `max-w-*` token controlling the column width. Default `\"lg\"`. */\n maxWidth?: MaxWidthToken;\n /** Tailwind `gap-*` token between tiles. Default `10`. */\n gap?: GapToken;\n /** CSS `perspective` in pixels applied to each tile. Default `900`. */\n perspective?: number;\n /**\n * Maximum `rotateX` tilt magnitude (in degrees) at the entry and exit poses.\n * Symmetric — entry tilts forward `+maxTilt`, exit tilts back `-maxTilt`.\n * Default `70`.\n */\n maxTilt?: number;\n /** Maximum blur (px) at the entry and exit poses. Default `8`. */\n maxBlur?: number;\n /**\n * CSS `border-radius` for the tile clipping mask. Accepts any CSS length value\n * (`\"0.375rem\"`, `\"12px\"`, `\"1rem\"`). Default `\"0.375rem\"` (Tailwind `rounded-md`).\n */\n rounded?: string;\n /**\n * Optional ref to a scrollable ancestor. When provided, scroll progress and the\n * loop sentinel are measured against this element instead of the viewport — use\n * this to embed the grid inside a fixed-height self-scrolling region (previews,\n * dialogs, etc.) instead of relying on page scroll.\n */\n container?: RefObject;\n /**\n * Hard cap on the total number of cycles when `loop` is on. Defaults to `Infinity`.\n * Set a finite value to bound DOM growth in long sessions.\n */\n maxCycles?: number;\n /**\n * Vertical breathing room around the grid — applied as both top/bottom margin\n * and top/bottom padding on the inner grid wrapper. Default `\"20vh\"` gives\n * full-page demos enough scroll runway. Set to `\"0\"` or a small `rem` value\n * when embedding in a bounded preview container.\n */\n sectionPadding?: string;\n /** Additional className applied to the outer `
`. */\n className?: string;\n};\n\n/**\n * Editorial scroll-tilted image grid. Pairs of images rise from below tipped\n * forward, settle into a clean focus, then tilt back over the top edge as they\n * exit. Optionally loops infinitely via an IntersectionObserver-driven append.\n */\nexport function ScrollTiltedGrid({\n images = DEFAULT_GRID_IMAGES,\n loop = false,\n initialCycles = 3,\n aspectRatio = \"3/4\",\n maxWidth = \"lg\",\n gap = 10,\n perspective = 900,\n maxTilt = 70,\n maxBlur = 8,\n rounded = \"0.375rem\",\n container,\n maxCycles = Infinity,\n sectionPadding = \"20vh\",\n className,\n}: ScrollTiltedGridProps = {}) {\n const [cycles, setCycles] = useState(\n loop ? Math.min(initialCycles, maxCycles) : 1,\n );\n const sentinelRef = useRef(null);\n\n // A single shared scroll position, owned by this component. The previous\n // implementation leaned on motion's useScroll({ container }) but its 'change'\n // events did not propagate reliably to per-tile subscribers — tiles below the\n // initial viewport would freeze at p=0 (entry pose) even after the user\n // scrolled. Owning the listener here makes the update path explicit.\n const scrollY = useMotionValue(0);\n useEffect(() => {\n const containerEl = container?.current ?? null;\n const target: HTMLElement | Window = containerEl ?? window;\n const read = () =>\n containerEl ? containerEl.scrollTop : window.scrollY || 0;\n scrollY.set(read());\n const update = () => scrollY.set(read());\n target.addEventListener(\"scroll\", update, { passive: true });\n return () => target.removeEventListener(\"scroll\", update);\n }, [container, scrollY]);\n\n useEffect(() => {\n if (!loop) return;\n const el = sentinelRef.current;\n if (!el) return;\n const obs = new IntersectionObserver(\n (entries) => {\n if (entries.some((e) => e.isIntersecting)) {\n setCycles((c) => (c >= maxCycles ? c : Math.min(c + 2, maxCycles)));\n }\n },\n {\n root: container?.current ?? null,\n rootMargin: \"1500px 0px 1500px 0px\",\n },\n );\n obs.observe(el);\n return () => obs.disconnect();\n }, [loop, container, maxCycles]);\n\n const items = useMemo(\n () =>\n loop ? Array.from({ length: cycles }, () => images).flat() : [...images],\n [loop, cycles, images],\n );\n\n const config = useMemo(\n () => ({\n aspectRatio,\n perspective,\n maxTilt,\n maxBlur,\n rounded,\n scrollY,\n container,\n }),\n [aspectRatio, perspective, maxTilt, maxBlur, rounded, scrollY, container],\n );\n\n const gridClass = [\n \"mx-auto grid w-full grid-cols-2 px-6\",\n MAX_WIDTH_CLASS[maxWidth],\n GAP_CLASS[gap],\n ]\n .filter(Boolean)\n .join(\" \");\n\n const gridStyle = {\n marginTop: sectionPadding,\n marginBottom: `calc(${sectionPadding} / 2)`,\n paddingTop: sectionPadding,\n paddingBottom: sectionPadding,\n };\n\n return (\n \n
\n {items.map((src, i) => (\n \n ))}\n
\n {loop ? (\n
\n ) : null}\n
\n );\n}\n", "type": "registry:ui", "target": "components/ruixen/scroll-tilted-grid.tsx" } ] }