{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-tilted-grid",
  "type": "registry:ui",
  "title": "Scroll Tilted Grid",
  "dependencies": [
    "framer-motion",
    "lenis"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "A cinematic image grid that tilts and resolves into focus as each frame crosses the viewport.",
  "files": [
    {
      "path": "components/ui/scroll-tilted-grid.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"framer-motion\";\nimport ReactLenis from \"lenis/react\";\nimport {\n  type CSSProperties,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nexport interface ScrollTiltedGridImage {\n  src: string;\n  alt: string;\n}\n\nexport interface ScrollTiltedGridProps {\n  images: readonly ScrollTiltedGridImage[];\n  loop?: boolean;\n  initialCycles?: number;\n  maxCycles?: number;\n  smoothScroll?: boolean;\n  aspectRatio?: string;\n  perspective?: number;\n  maxTilt?: number;\n  maxBlur?: number;\n  rounded?: string;\n  sectionPadding?: string;\n  className?: string;\n}\n\ntype TileVariables = CSSProperties & {\n  \"--tile-blur\": string;\n  \"--tile-brightness\": number;\n  \"--tile-saturation\": number;\n  \"--tile-transform\": string;\n  \"--tile-image-scale\": number;\n};\n\nfunction clamp(value: number, minimum = 0, maximum = 1) {\n  return Math.min(maximum, Math.max(minimum, value));\n}\n\nfunction GalleryTile({\n  image,\n  index,\n  aspectRatio,\n  perspective,\n  maxTilt,\n  maxBlur,\n  rounded,\n  reduceMotion,\n}: {\n  image: ScrollTiltedGridImage;\n  index: number;\n  aspectRatio: string;\n  perspective: number;\n  maxTilt: number;\n  maxBlur: number;\n  rounded: string;\n  reduceMotion: boolean;\n}) {\n  const tileRef = useRef<HTMLElement>(null);\n  const side = index % 2 === 0 ? -1 : 1;\n\n  useEffect(() => {\n    const tile = tileRef.current;\n    if (!tile || reduceMotion) return;\n\n    let frame = 0;\n    const update = () => {\n      frame = 0;\n      const rect = tile.getBoundingClientRect();\n      const travel = window.innerHeight + rect.height;\n      const position = clamp((window.innerHeight - rect.top) / travel);\n      const distance = Math.abs(position - 0.5) * 2;\n      const signed = (position - 0.5) * 2;\n      const eased = distance * distance * (3 - 2 * distance);\n      const x = side * eased * 18;\n      const y = -signed * eased * 24;\n      const tilt = -signed * maxTilt;\n      const roll = side * signed * 3;\n      const skew = -side * signed * 7;\n\n      tile.style.setProperty(\"--tile-blur\", `${eased * maxBlur}px`);\n      tile.style.setProperty(\"--tile-brightness\", String(1 - eased * 0.5));\n      tile.style.setProperty(\"--tile-saturation\", String(1 - eased * 0.5));\n      tile.style.setProperty(\"--tile-image-scale\", String(1.03 + eased * 0.15));\n      tile.style.setProperty(\n        \"--tile-transform\",\n        `translate3d(${x}%, ${y}%, ${eased * 180}px) rotateX(${tilt}deg) rotateZ(${roll}deg) skewX(${skew}deg)`,\n      );\n    };\n    const schedule = () => {\n      if (!frame) frame = window.requestAnimationFrame(update);\n    };\n\n    update();\n    const resizeObserver = new ResizeObserver(schedule);\n    resizeObserver.observe(tile);\n    window.addEventListener(\"scroll\", schedule, { passive: true });\n    window.addEventListener(\"resize\", schedule);\n\n    return () => {\n      resizeObserver.disconnect();\n      window.removeEventListener(\"scroll\", schedule);\n      window.removeEventListener(\"resize\", schedule);\n      if (frame) window.cancelAnimationFrame(frame);\n    };\n  }, [maxBlur, maxTilt, reduceMotion, side]);\n\n  const variables: TileVariables = {\n    aspectRatio,\n    borderRadius: rounded,\n    perspective,\n    \"--tile-blur\": \"0px\",\n    \"--tile-brightness\": 1,\n    \"--tile-saturation\": 1,\n    \"--tile-transform\": \"translate3d(0, 0, 0)\",\n    \"--tile-image-scale\": 1.03,\n  };\n\n  return (\n    <figure\n      ref={tileRef}\n      className={cn(\"m-0\", side > 0 && \"pt-12 sm:pt-24\")}\n      style={variables}\n    >\n      <div\n        className={cn(\n          \"relative w-full overflow-hidden border border-black/10 bg-neutral-200 shadow-[0_24px_80px_rgba(20,18,14,0.16)] dark:border-white/10 dark:bg-neutral-900 dark:shadow-[0_24px_90px_rgba(0,0,0,0.45)]\",\n          !reduceMotion &&\n            \"[filter:blur(var(--tile-blur))_brightness(var(--tile-brightness))_saturate(var(--tile-saturation))] [transform:var(--tile-transform)] [transform-style:preserve-3d]\",\n        )}\n        style={{ aspectRatio, borderRadius: rounded }}\n      >\n        <img\n          src={image.src}\n          alt={image.alt}\n          className={cn(\n            \"h-full w-full object-cover\",\n            !reduceMotion && \"[transform:scale(var(--tile-image-scale))]\",\n          )}\n          loading={index < 4 ? \"eager\" : \"lazy\"}\n          draggable={false}\n        />\n        <span className=\"pointer-events-none absolute inset-0 bg-gradient-to-b from-white/5 via-transparent to-black/10\" />\n      </div>\n    </figure>\n  );\n}\n\nexport function ScrollTiltedGrid({\n  images,\n  loop = false,\n  initialCycles = 2,\n  maxCycles = 4,\n  smoothScroll = true,\n  aspectRatio = \"4 / 5\",\n  perspective = 1000,\n  maxTilt = 62,\n  maxBlur = 7,\n  rounded = \"0.25rem\",\n  sectionPadding = \"18vh\",\n  className,\n}: ScrollTiltedGridProps) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const cycleLimit = Math.max(1, maxCycles);\n  const [cycleCount, setCycleCount] = useState(() =>\n    clamp(initialCycles, 1, cycleLimit),\n  );\n  const loadMoreRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const marker = loadMoreRef.current;\n    if (!loop || !marker) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry?.isIntersecting) {\n          setCycleCount((count) => Math.min(cycleLimit, count + 1));\n        }\n      },\n      { rootMargin: \"1200px 0px\" },\n    );\n    observer.observe(marker);\n    return () => observer.disconnect();\n  }, [cycleLimit, loop]);\n\n  const tiles = useMemo(\n    () =>\n      Array.from({ length: loop ? cycleCount : 1 }, (_, cycle) =>\n        images.map((image, index) => ({ cycle, image, index })),\n      ).flat(),\n    [cycleCount, images, loop],\n  );\n\n  const gallery = (\n    <section\n      className={cn(\"relative w-full overflow-hidden\", className)}\n      aria-label=\"Scroll-reactive image gallery\"\n    >\n      <div\n        className=\"mx-auto grid w-full max-w-5xl grid-cols-2 items-start gap-x-4 gap-y-16 px-4 sm:gap-x-10 sm:gap-y-28 sm:px-10 lg:gap-x-16\"\n        style={{ paddingBlock: sectionPadding }}\n      >\n        {tiles.map(({ cycle, image, index }) => (\n          <GalleryTile\n            key={`${cycle}-${index}-${image.src}`}\n            image={image}\n            index={index}\n            aspectRatio={aspectRatio}\n            perspective={perspective}\n            maxTilt={maxTilt}\n            maxBlur={maxBlur}\n            rounded={rounded}\n            reduceMotion={reduceMotion}\n          />\n        ))}\n      </div>\n      {loop && cycleCount < cycleLimit ? (\n        <div ref={loadMoreRef} className=\"h-px\" aria-hidden />\n      ) : null}\n    </section>\n  );\n\n  return smoothScroll && !reduceMotion ? (\n    <ReactLenis\n      root\n      options={{ autoRaf: true, lerp: 0.075, wheelMultiplier: 0.85 }}\n    >\n      {gallery}\n    </ReactLenis>\n  ) : (\n    gallery\n  );\n}\n",
      "type": "registry:ui"
    }
  ]
}