{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "perspective-flow",
  "title": "Perspective Flow",
  "description": "Scroll-driven 3D image grid with perspective rotations, blur, brightness, and inner parallax — supports both GSAP ScrollTrigger and Framer Motion engines.",
  "dependencies": [
    "framer-motion",
    "gsap"
  ],
  "files": [
    {
      "path": "registry/components/unlumen/perspective-flow/index.tsx",
      "content": "\"use client\";\n\nimport { useRef, useEffect, type CSSProperties, type RefObject } from \"react\";\nimport {\n  motion,\n  useScroll,\n  useTransform,\n  type MotionValue,\n} from \"framer-motion\";\nimport gsap from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(ScrollTrigger);\n\nexport interface PerspectiveFlowImage {\n  src: string;\n  alt?: string;\n}\n\nexport interface PerspectiveFlowProps {\n  /** Array of image sources to display in the grid. */\n  images: PerspectiveFlowImage[];\n  /** Animation engine — `\"gsap\"` uses GSAP ScrollTrigger, `\"motion\"` uses Framer Motion useScroll. */\n  engine?: \"gsap\" | \"motion\";\n  /** Perspective depth in pixels applied to each card. @default 1000 */\n  perspective?: number;\n  /** Max grid width. @default \"900px\" */\n  maxWidth?: string;\n  /** Gap between grid items. Accepts px number or CSS string. @default \"1.5rem\" */\n  gap?: number | string;\n  /** Vertical gap between grid rows. Defaults to `gap` when not provided. */\n  verticalGap?: number | string;\n  /** Minimum card width used by auto-fit responsive columns. @default 240 */\n  minItemWidth?: number;\n  /** Optional scrollable container element used as the animation scroller. */\n  scrollContainerRef?: RefObject<HTMLElement | null>;\n  /** Additional class name on the root element. */\n  className?: string;\n}\n\nconst DEFAULT_PERSPECTIVE = 1000;\nconst DEFAULT_MIN_ITEM_WIDTH = 240;\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value));\n}\n\nfunction toCssLength(value: number | string | undefined, fallback: string) {\n  if (typeof value === \"number\") return `${value}px`;\n  return value ?? fallback;\n}\n\nfunction useFilterTransform(\n  blur: MotionValue<number>,\n  brightness: MotionValue<number>,\n  contrast: MotionValue<number>,\n) {\n  return useTransform(\n    [blur, brightness, contrast],\n    ([b, br, c]: number[]) => `blur(${b}px) brightness(${br}%) contrast(${c}%)`,\n  );\n}\n\nfunction MotionCard({\n  src,\n  alt,\n  isLeft,\n  perspective,\n  dynamicsScale,\n  scrollContainerRef,\n}: {\n  src: string;\n  alt: string;\n  isLeft: boolean;\n  perspective: number;\n  dynamicsScale: number;\n  scrollContainerRef?: RefObject<HTMLElement | null>;\n}) {\n  const ref = useRef<HTMLElement>(null);\n\n  const { scrollYProgress } = useScroll({\n    target: ref,\n    container: scrollContainerRef,\n    offset: [\"start end\", \"end start\"],\n  });\n\n  const tiltScale = 0.6 + dynamicsScale * 0.4;\n  const pct = (value: number) => `${value * dynamicsScale}%`;\n\n  const rotateX = useTransform(\n    scrollYProgress,\n    [0, 0.5, 1],\n    [70 * tiltScale, 0, -50 * tiltScale],\n  );\n  const rotateZ = useTransform(\n    scrollYProgress,\n    [0, 0.5, 1],\n    isLeft\n      ? [5 * tiltScale, 0, -1 * tiltScale]\n      : [-5 * tiltScale, 0, 1 * tiltScale],\n  );\n  const x = useTransform(\n    scrollYProgress,\n    [0, 0.5, 0.7, 1],\n    isLeft ? [pct(-60), \"0%\", \"0%\", pct(-10)] : [pct(40), \"0%\", \"0%\", pct(10)],\n  );\n  const skewX = useTransform(\n    scrollYProgress,\n    [0, 0.5, 1],\n    isLeft\n      ? [-5 * tiltScale, 0, 5 * tiltScale]\n      : [5 * tiltScale, 0, -5 * tiltScale],\n  );\n  const y = useTransform(\n    scrollYProgress,\n    [0, 0.5, 1],\n    [pct(40), \"0%\", pct(-10)],\n  );\n\n  const blur = useTransform(scrollYProgress, [0, 0.5, 1], [7, 0, 4]);\n  const brightness = useTransform(scrollYProgress, [0, 0.5, 1], [0, 100, 0]);\n  const contrast = useTransform(scrollYProgress, [0, 0.5, 1], [180, 110, 180]);\n  const scaleY = useTransform(scrollYProgress, [0, 0.5, 1], [1.8, 1, 1.1]);\n\n  const filter = useFilterTransform(blur, brightness, contrast);\n\n  return (\n    <motion.figure\n      ref={ref}\n      className=\"relative z-10 m-0\"\n      style={\n        {\n          perspective: `${perspective}px`,\n          willChange: \"transform\",\n        } as CSSProperties\n      }\n    >\n      <motion.div\n        className=\"relative aspect-[1/1.2] w-full overflow-hidden rounded-xs\"\n        style={{ y, x, rotateX, rotateZ, skewX, filter, scaleY }}\n      >\n        <motion.div\n          className=\"absolute inset-0 h-full w-full bg-cover bg-center\"\n          style={{ backgroundImage: `url(${src})` }}\n          role=\"img\"\n          aria-label={alt}\n        />\n      </motion.div>\n    </motion.figure>\n  );\n}\n\nfunction MotionGrid({\n  images,\n  perspective = DEFAULT_PERSPECTIVE,\n  maxWidth = \"900px\",\n  gap = \"1.5rem\",\n  verticalGap,\n  minItemWidth = DEFAULT_MIN_ITEM_WIDTH,\n  scrollContainerRef,\n  className,\n}: Omit<PerspectiveFlowProps, \"engine\">) {\n  const dynamicsScale = clamp(perspective / DEFAULT_PERSPECTIVE, 0.35, 1.2);\n  const spacingScale = clamp(1 / dynamicsScale, 0.85, 1.8);\n  const maxWidthCss = toCssLength(maxWidth, \"900px\");\n  const columnGap = toCssLength(gap, \"1.5rem\");\n  const rowGap = toCssLength(verticalGap, columnGap);\n  const effectiveColumnGap = `calc(${columnGap} * ${spacingScale})`;\n  const effectiveRowGap = `calc(${rowGap} * ${spacingScale})`;\n  const constrainedMaxWidth = `min(${maxWidthCss}, calc(${minItemWidth * 2}px + ${effectiveColumnGap}))`;\n\n  return (\n    <div className={cn(\"relative w-full overflow-hidden\", className)}>\n      <div className=\"relative w-full overflow-hidden\">\n        <section className=\"relative grid w-full place-items-center\">\n          <div\n            className=\"relative mb-[10vh] grid w-full py-[20vh]\"\n            style={{\n              maxWidth: constrainedMaxWidth,\n              columnGap: effectiveColumnGap,\n              rowGap: effectiveRowGap,\n              gridTemplateColumns: `repeat(auto-fit, minmax(${minItemWidth}px, 1fr))`,\n            }}\n          >\n            {images.map((img, i) => {\n              const isLeft = i % 2 === 0;\n              return (\n                <MotionCard\n                  key={i}\n                  src={img.src}\n                  alt={img.alt ?? `Image ${i + 1}`}\n                  isLeft={isLeft}\n                  perspective={perspective}\n                  dynamicsScale={dynamicsScale}\n                  scrollContainerRef={scrollContainerRef}\n                />\n              );\n            })}\n          </div>\n        </section>\n      </div>\n    </div>\n  );\n}\n\nfunction GsapGrid({\n  images,\n  perspective = DEFAULT_PERSPECTIVE,\n  maxWidth = \"900px\",\n  gap = \"1.5rem\",\n  verticalGap,\n  minItemWidth = DEFAULT_MIN_ITEM_WIDTH,\n  scrollContainerRef,\n  className,\n}: Omit<PerspectiveFlowProps, \"engine\">) {\n  const gridRef = useRef<HTMLDivElement>(null);\n  const dynamicsScale = clamp(perspective / DEFAULT_PERSPECTIVE, 0.35, 1.2);\n  const spacingScale = clamp(1 / dynamicsScale, 0.85, 1.8);\n  const tiltScale = 0.6 + dynamicsScale * 0.4;\n  const maxWidthCss = toCssLength(maxWidth, \"900px\");\n  const columnGap = toCssLength(gap, \"1.5rem\");\n  const rowGap = toCssLength(verticalGap, columnGap);\n  const effectiveColumnGap = `calc(${columnGap} * ${spacingScale})`;\n  const effectiveRowGap = `calc(${rowGap} * ${spacingScale})`;\n  const constrainedMaxWidth = `min(${maxWidthCss}, calc(${minItemWidth * 2}px + ${effectiveColumnGap}))`;\n\n  useEffect(() => {\n    let context: gsap.Context | null = null;\n\n    const timeout = setTimeout(() => {\n      context = gsap.context(() => {\n        const wraps = gridRef.current?.querySelectorAll(\".ps-imgwrap\");\n        if (!wraps) return;\n\n        wraps.forEach((wrap) => {\n          const inner = wrap.querySelector(\".ps-img\");\n          const rect = wrap.getBoundingClientRect();\n          const scrollerRect =\n            scrollContainerRef?.current?.getBoundingClientRect();\n          const referenceCenterX =\n            scrollerRect?.left !== undefined &&\n            scrollerRect?.width !== undefined\n              ? scrollerRect.left + scrollerRect.width / 2\n              : window.innerWidth / 2;\n          const isLeft = rect.left + rect.width / 2 < referenceCenterX;\n\n          gsap\n            .timeline({\n              scrollTrigger: {\n                trigger: wrap,\n                scroller: scrollContainerRef?.current ?? undefined,\n                start: \"top bottom+=10%\",\n                end: \"bottom top-=25%\",\n                scrub: true,\n              },\n            })\n            .from(wrap, {\n              startAt: {\n                filter: \"blur(0px) brightness(100%) contrast(100%)\",\n              },\n              z: 300 * dynamicsScale,\n              rotateX: 70 * tiltScale,\n              rotateZ: isLeft ? 5 * tiltScale : -5 * tiltScale,\n              xPercent: (isLeft ? -40 : 40) * dynamicsScale,\n              skewX: (isLeft ? -20 : 20) * tiltScale,\n              yPercent: 100 * dynamicsScale,\n              filter: \"blur(7px) brightness(0%) contrast(400%)\",\n              ease: \"sine\",\n            })\n            .to(wrap, {\n              z: 300 * dynamicsScale,\n              rotateX: -50 * tiltScale,\n              rotateZ: isLeft ? -1 * tiltScale : 1 * tiltScale,\n              xPercent: (isLeft ? -20 : 20) * dynamicsScale,\n              skewX: (isLeft ? 10 : -10) * tiltScale,\n              filter: \"blur(4px) brightness(0%) contrast(500%)\",\n              ease: \"sine.in\",\n            })\n            .from(inner, { scaleY: 1.8, ease: \"sine\" }, 0)\n            .to(inner, { scaleY: 1.8, ease: \"sine.in\" }, \">\");\n        });\n      }, gridRef);\n\n      ScrollTrigger.refresh();\n    }, 100);\n\n    return () => {\n      clearTimeout(timeout);\n      context?.revert();\n    };\n  }, [\n    scrollContainerRef,\n    images,\n    perspective,\n    dynamicsScale,\n    tiltScale,\n    minItemWidth,\n    maxWidthCss,\n    effectiveColumnGap,\n    effectiveRowGap,\n  ]);\n\n  return (\n    <div className={cn(\"relative w-full overflow-hidden\", className)}>\n      <div className=\"relative w-full overflow-hidden\">\n        <section className=\"relative grid w-full place-items-center\">\n          <div\n            ref={gridRef}\n            className=\"relative mb-[10vh] grid w-full py-[20vh]\"\n            style={{\n              maxWidth: constrainedMaxWidth,\n              columnGap: effectiveColumnGap,\n              rowGap: effectiveRowGap,\n              gridTemplateColumns: `repeat(auto-fit, minmax(${minItemWidth}px, 1fr))`,\n            }}\n          >\n            {images.map((img, i) => (\n              <figure\n                key={i}\n                className=\"relative z-10 m-0\"\n                style={\n                  {\n                    perspective: `${perspective}px`,\n                    willChange: \"transform\",\n                  } as CSSProperties\n                }\n              >\n                <div className=\"ps-imgwrap relative aspect-[1/1.2] w-full overflow-hidden rounded-xs will-change-[filter]\">\n                  <div\n                    className=\"ps-img absolute inset-0 h-full w-full bg-cover bg-center will-change-transform\"\n                    style={{\n                      backgroundImage: `url(${img.src})`,\n                      backfaceVisibility: \"hidden\",\n                    }}\n                    role=\"img\"\n                    aria-label={img.alt ?? `Image ${i + 1}`}\n                  />\n                </div>\n              </figure>\n            ))}\n          </div>\n        </section>\n      </div>\n    </div>\n  );\n}\n\nexport function PerspectiveFlow({\n  engine = \"motion\",\n  ...props\n}: PerspectiveFlowProps) {\n  if (engine === \"gsap\") return <GsapGrid {...props} />;\n  return <MotionGrid {...props} />;\n}\n\nexport default PerspectiveFlow;\n",
      "type": "registry:ui",
      "target": "components/unlumen-ui/perspective-flow.tsx"
    }
  ],
  "type": "registry:ui"
}