{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bend-image-reveal",
  "description": "A pixel-art frame that flies in and warps with an air-friction bend, deformed on a WebGL plane by a GSAP-driven vertex shader.",
  "dependencies": [
    "@react-three/fiber",
    "gsap",
    "three"
  ],
  "files": [
    {
      "path": "registry/new-york/gsap/bend-image-reveal.tsx",
      "content": "\"use client\";\n\n/* eslint-disable react-hooks/immutability */\n\n/**\n * A self-contained, scrollable image collage whose frames warp with an air-friction bend driven by scroll velocity, on WebGL planes.\n */\n\nimport { RefObject, useEffect, useRef, useState } from \"react\";\nimport { Canvas, useFrame, useThree } from \"@react-three/fiber\";\nimport * as THREE from \"three\";\nimport gsap from \"gsap\";\n\nconst SRC = \"/bend-image-reveal.gif\";\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n\nconst vertexShader = /* glsl */ `\n  varying vec2 vUv;\n  uniform float uBendY;\n  void main() {\n    vUv = uv;\n    vec3 pos = position;\n    float px = uv.x * 2.0 - 1.0;  // -1 (left) .. +1 (right)\n    pos.y += uBendY * px * px;    // parabola: 0 at center, max at both edges\n    gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);\n  }\n`;\n\nconst fragmentShader = /* glsl */ `\n  varying vec2 vUv;\n  uniform sampler2D uTex;\n  void main() {\n    gl_FragColor = texture2D(uTex, vUv);\n  }\n`;\n\nfunction Collage({\n  scrollRef,\n  slots,\n  count,\n}: {\n  scrollRef: RefObject<HTMLDivElement | null>;\n  slots: RefObject<HTMLDivElement[]>;\n  count: number;\n}) {\n  const { gl } = useThree();\n  const [texture, setTexture] = useState<THREE.Texture | null>(null);\n  const meshes = useRef<THREE.Mesh[]>([]);\n\n  const [material] = useState(\n    () =>\n      new THREE.ShaderMaterial({\n        vertexShader,\n        fragmentShader,\n        uniforms: { uTex: { value: null as THREE.Texture | null }, uBendY: { value: 0 } },\n      }),\n  );\n  const [geometry] = useState(() => new THREE.PlaneGeometry(1, 1, 32, 24));\n\n  useEffect(() => {\n    new THREE.TextureLoader().load(SRC, (t) => {\n      t.colorSpace = THREE.SRGBColorSpace;\n      t.magFilter = THREE.NearestFilter;\n      material.uniforms.uTex.value = t;\n      setTexture(t);\n    });\n  }, [material]);\n\n  const lastScroll = useRef(0);\n  const bend = useRef(0);\n  useFrame(() => {\n    const scroller = scrollRef.current;\n    if (!scroller) return;\n    const canvasRect = gl.domElement.getBoundingClientRect();\n\n    for (let i = 0; i < meshes.current.length; i++) {\n      const slot = slots.current[i];\n      const mesh = meshes.current[i];\n      if (!slot || !mesh) continue;\n      const r = slot.getBoundingClientRect();\n      const cx = r.left + r.width / 2 - canvasRect.left;\n      const cy = r.top + r.height / 2 - canvasRect.top;\n      mesh.position.set(cx - canvasRect.width / 2, -(cy - canvasRect.height / 2), 0);\n      mesh.scale.set(r.width, r.height, 1);\n    }\n\n    const s = scroller.scrollTop;\n    const vel = s - lastScroll.current;\n    lastScroll.current = s;\n    const target = gsap.utils.clamp(-0.3, 0.3, -vel * 0.02);\n    bend.current = lerp(bend.current, target, 0.15);\n    material.uniforms.uBendY.value = bend.current;\n  });\n\n  if (!texture) return null;\n  return (\n    <>\n      {Array.from({ length: count }).map((_, i) => (\n        <mesh\n          key={i}\n          ref={(el) => {\n            if (el) meshes.current[i] = el;\n          }}\n          geometry={geometry}\n          material={material}\n          frustumCulled={false}\n        />\n      ))}\n    </>\n  );\n}\n\nconst Page = ({ columns = 2 }: { columns?: 1 | 2 }) => {\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const slots = useRef<HTMLDivElement[]>([]);\n  const count = columns === 2 ? 8 : 5;\n\n  return (\n    <div\n      className={`relative overflow-hidden rounded-md bg-neutral-950 ${\n        columns === 2 ? \"h-full w-full\" : \"h-72 w-72\"\n      }`}\n    >\n      <Canvas\n        className=\"pointer-events-none absolute inset-0 z-10\"\n        orthographic\n        camera={{ position: [0, 0, 100], zoom: 1, near: 0.1, far: 1000 }}\n        dpr={[1, 2]}\n        gl={{ antialias: true, alpha: true }}\n      >\n        <Collage scrollRef={scrollRef} slots={slots} count={count} />\n      </Canvas>\n\n      <div\n        ref={scrollRef}\n        className=\"scrollbar-none absolute inset-0 overflow-y-auto overscroll-contain p-2\"\n      >\n        <div className={`grid gap-2 ${columns === 2 ? \"grid-cols-2\" : \"grid-cols-1\"}`}>\n          {Array.from({ length: count }).map((_, i) => (\n            <div\n              key={i}\n              ref={(el) => {\n                if (el) slots.current[i] = el;\n              }}\n              className=\"aspect-video w-full rounded bg-neutral-900\"\n            />\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default Page;\n",
      "type": "registry:component",
      "target": "components/pixel-perfect/bend-image-reveal.tsx"
    }
  ],
  "type": "registry:block"
}