{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bend-zoom-reveal",
  "description": "A pixel-art frame that flies in, then zooms to fill the box as you scroll inside it, warping with an air-friction bend on a WebGL plane.",
  "dependencies": [
    "@react-three/fiber",
    "gsap",
    "three"
  ],
  "files": [
    {
      "path": "registry/new-york/gsap/bend-zoom-reveal.tsx",
      "content": "\"use client\";\n\n/**\n * A pixel-art frame that flies in, then zooms to fill the box as you scroll inside it, warping with an air-friction bend on a WebGL plane.\n */\n\nimport { RefObject, useEffect, useMemo, 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 IMG_ASPECT = 540 / 304;\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n\nconst vertexShader = /* glsl */ `\n  varying vec2 vUv;\n  uniform float uBendX;\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    float py = uv.y * 2.0 - 1.0; // -1 (bottom) .. +1 (top)\n    pos.y += uBendY * px * px;\n    pos.x += uBendX * py * py;\n    gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);\n  }\n`;\n\nconst fragmentShader = /* glsl */ `\n  varying vec2 vUv;\n  uniform sampler2D uTex;\n  uniform float uOpacity;\n  void main() {\n    vec4 c = texture2D(uTex, vUv);\n    c.a *= uOpacity;\n    gl_FragColor = c;\n  }\n`;\n\nfunction BendImage({ scrollRef }: { scrollRef: RefObject<HTMLDivElement | null> }) {\n  const meshRef = useRef<THREE.Mesh>(null);\n  const matRef = useRef<THREE.ShaderMaterial>(null);\n  const { size } = useThree();\n  const [texture, setTexture] = useState<THREE.Texture | null>(null);\n\n  const uniforms = useMemo(\n    () => ({\n      uTex: { value: null as THREE.Texture | null },\n      uBendX: { value: 0 },\n      uBendY: { value: 0 },\n      uOpacity: { value: 0 },\n    }),\n    [],\n  );\n\n  const planeH = Math.min(size.height * 0.42, (size.width * 0.7) / IMG_ASPECT);\n  const planeW = planeH * IMG_ASPECT;\n  const coverScale = Math.max(size.width / planeW, size.height / planeH);\n\n  useEffect(() => {\n    new THREE.TextureLoader().load(SRC, (t) => {\n      t.colorSpace = THREE.SRGBColorSpace;\n      t.magFilter = THREE.NearestFilter;\n      setTexture(t);\n    });\n  }, []);\n\n  const state = useRef({ x: 0, y: 0, s: 0, bx: 0, by: 0 });\n\n  useEffect(() => {\n    const mesh = meshRef.current;\n    const mat = matRef.current;\n    if (!texture || !mesh || !mat) return;\n    mat.uniforms.uTex.value = texture;\n\n    const rightX = size.width * 0.28;\n    const topY = size.height * 0.28;\n    const bottomY = -size.height * 0.28;\n\n    mesh.position.set(rightX, bottomY, 0);\n    state.current.x = rightX;\n    state.current.y = bottomY;\n\n    const tl = gsap\n      .timeline({ defaults: { ease: \"power3.inOut\" } })\n      .to(mat.uniforms.uOpacity, { value: 1, duration: 0.3 })\n      .to(mesh.position, { y: topY, duration: 0.6 })\n      .to(mesh.position, { x: 0, y: 0, duration: 0.8 });\n\n    return () => {\n      tl.kill();\n    };\n  }, [texture, size.width, size.height]);\n\n  useFrame(() => {\n    const mesh = meshRef.current;\n    const mat = matRef.current;\n    const sc = scrollRef.current;\n    if (!mesh || !mat) return;\n\n    const max = sc ? Math.max(1, sc.scrollHeight - sc.clientHeight) : 1;\n    const progress = sc ? gsap.utils.clamp(0, 1, sc.scrollTop / max) : 0;\n    const scale = lerp(1, coverScale, progress);\n    mesh.scale.set(scale, scale, 1);\n\n    const pvx = mesh.position.x - state.current.x;\n    const pvy = mesh.position.y - state.current.y;\n    const sv = sc ? sc.scrollTop - state.current.s : 0;\n    state.current.x = mesh.position.x;\n    state.current.y = mesh.position.y;\n    state.current.s = sc ? sc.scrollTop : 0;\n\n    const targetX = gsap.utils.clamp(-45, 45, -pvx * 2);\n    const targetY = gsap.utils.clamp(-45, 45, -pvy * 2 + sv * -1.4);\n    state.current.bx = lerp(state.current.bx, targetX, 0.12);\n    state.current.by = lerp(state.current.by, targetY, 0.12);\n    const s = mesh.scale.x || 1;\n    mat.uniforms.uBendX.value = state.current.bx / s;\n    mat.uniforms.uBendY.value = state.current.by / s;\n  });\n\n  if (!texture) return null;\n  return (\n    <mesh ref={meshRef}>\n      <planeGeometry args={[planeW, planeH, 48, 48]} />\n      <shaderMaterial\n        ref={matRef}\n        vertexShader={vertexShader}\n        fragmentShader={fragmentShader}\n        uniforms={uniforms}\n        transparent\n      />\n    </mesh>\n  );\n}\n\nconst Page = ({ columns = 1 }: { columns?: 1 | 2 }) => {\n  const scrollRef = useRef<HTMLDivElement>(null);\n\n  return (\n    <div\n      className={`relative overflow-hidden rounded-md ${\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        <BendImage scrollRef={scrollRef} />\n      </Canvas>\n\n      <div\n        ref={scrollRef}\n        className=\"scrollbar-none absolute inset-0 overflow-y-auto overscroll-contain\"\n      >\n        <div style={{ height: \"320%\" }} />\n      </div>\n    </div>\n  );\n};\n\nexport default Page;\n",
      "type": "registry:component",
      "target": "components/pixel-perfect/bend-zoom-reveal.tsx"
    }
  ],
  "type": "registry:block"
}