{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-ripple-effect",
  "type": "registry:ui",
  "title": "Image Ripple Effect",
  "dependencies": [
    "three",
    "@types/three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "A cursor-driven WebGL ripple distortion effect for image cards.",
  "files": [
    {
      "path": "components/ui/image-ripple-effect.tsx",
      "content": "\"use client\";\n\nimport { OrthographicCamera, useFBO, useTexture } from \"@react-three/drei\";\nimport { Canvas, useFrame, useThree } from \"@react-three/fiber\";\nimport { cn } from \"@/lib/utils\";\nimport * as React from \"react\";\nimport * as THREE from \"three\";\nimport { WebGLErrorBoundary, WebGLFallback } from \"./webgl-error-boundary\";\n\nconst fragmentShader = `\nuniform sampler2D uTexture;\nuniform sampler2D uDisplacement;\nuniform vec2 winResolution;\nuniform float uStrength;\n\nconst float PI = 3.141592653589793238;\n\nvoid main() {\n  vec2 vUvScreen = gl_FragCoord.xy / winResolution.xy;\n  vec4 displacement = texture2D(uDisplacement, vUvScreen);\n  float theta = displacement.r * 2.0 * PI;\n\n  vec2 dir = vec2(sin(theta), cos(theta));\n  vec2 uv = vUvScreen + dir * displacement.r * uStrength;\n  vec4 color = texture2D(uTexture, uv);\n\n  gl_FragColor = color;\n}\n`;\n\nconst vertexShader = `\nvarying vec2 vUv;\n\nvoid main() {\n  vUv = uv;\n  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n}\n`;\n\nconst BRUSH_DATA_URI = `data:image/svg+xml;utf8,${encodeURIComponent(`\n  <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"128\" height=\"128\">\n    <defs>\n      <radialGradient id=\"g\" cx=\"50%\" cy=\"50%\" r=\"50%\">\n        <stop offset=\"0%\" stop-color=\"white\" stop-opacity=\"1\"/>\n        <stop offset=\"65%\" stop-color=\"white\" stop-opacity=\"0.55\"/>\n        <stop offset=\"100%\" stop-color=\"white\" stop-opacity=\"0\"/>\n      </radialGradient>\n    </defs>\n    <rect width=\"128\" height=\"128\" fill=\"url(#g)\"/>\n  </svg>\n`)}`;\n\nfunction createDemoImage(title: string, colorA: string, colorB: string) {\n  const svg = `\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 800 1000\">\n      <defs>\n        <linearGradient id=\"g\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">\n          <stop offset=\"0%\" stop-color=\"${colorA}\" />\n          <stop offset=\"100%\" stop-color=\"${colorB}\" />\n        </linearGradient>\n      </defs>\n      <rect width=\"800\" height=\"1000\" fill=\"url(#g)\"/>\n      <circle cx=\"610\" cy=\"180\" r=\"130\" fill=\"white\" fill-opacity=\"0.12\"/>\n      <circle cx=\"180\" cy=\"760\" r=\"190\" fill=\"white\" fill-opacity=\"0.12\"/>\n      <text x=\"64\" y=\"900\" fill=\"white\" font-size=\"64\" font-family=\"system-ui, sans-serif\" opacity=\"0.9\">\n        ${title}\n      </text>\n    </svg>\n  `;\n  return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;\n}\n\nconst DEFAULT_IMAGE_URLS = [createDemoImage(\"Aurora\", \"#0f172a\", \"#155e75\")];\n\nexport interface RippleImageItem {\n  src: string;\n  x?: number;\n  y?: number;\n  widthScale?: number;\n  heightScale?: number;\n}\n\nexport interface ImageRippleEffectProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  className?: string;\n  images?: RippleImageItem[];\n  brushTextureUrl?: string;\n  distortionStrength?: number;\n  waveCount?: number;\n  waveSize?: number;\n  waveRotationSpeed?: number;\n  waveFadeMultiplier?: number;\n  waveGrowth?: number;\n  waveSpawnThreshold?: number;\n  children?: React.ReactNode;\n}\n\ntype ViewportDimensions = {\n  width: number;\n  height: number;\n  pixelRatio: number;\n};\n\nfunction useContainerDimensions(\n  ref: React.RefObject<HTMLElement | null>,\n): ViewportDimensions {\n  const [dimensions, setDimensions] = React.useState<ViewportDimensions>({\n    width: 0,\n    height: 0,\n    pixelRatio: 1,\n  });\n\n  React.useEffect(() => {\n    const element = ref.current;\n    if (!element) {\n      return;\n    }\n\n    const updateSize = () => {\n      const rect = element.getBoundingClientRect();\n      setDimensions({\n        width: Math.round(rect.width),\n        height: Math.round(rect.height),\n        pixelRatio:\n          typeof window !== \"undefined\" ? Math.min(window.devicePixelRatio, 2) : 1,\n      });\n    };\n\n    updateSize();\n    const observer = new ResizeObserver(updateSize);\n    observer.observe(element);\n    window.addEventListener(\"resize\", updateSize);\n\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"resize\", updateSize);\n    };\n  }, [ref]);\n\n  return dimensions;\n}\n\ninterface RippleSceneProps {\n  width: number;\n  height: number;\n  pixelRatio: number;\n  pointerRef: React.MutableRefObject<{ x: number; y: number }>;\n  images: RippleImageItem[];\n  brushTextureUrl: string;\n  distortionStrength: number;\n  waveCount: number;\n  waveSize: number;\n  waveRotationSpeed: number;\n  waveFadeMultiplier: number;\n  waveGrowth: number;\n  waveSpawnThreshold: number;\n}\n\nfunction RippleScene({\n  width,\n  height,\n  pixelRatio,\n  pointerRef,\n  images,\n  brushTextureUrl,\n  distortionStrength,\n  waveCount,\n  waveSize,\n  waveRotationSpeed,\n  waveFadeMultiplier,\n  waveGrowth,\n  waveSpawnThreshold,\n}: RippleSceneProps) {\n  const { viewport } = useThree();\n  const { gl, camera } = useThree();\n\n  const brushTexture = useTexture(brushTextureUrl);\n  const imageTextures = useTexture(images.map((item) => item.src));\n  const rippleScene = React.useMemo(() => new THREE.Scene(), []);\n  const imageScene = React.useMemo(() => new THREE.Scene(), []);\n\n  const waveMeshesRef = React.useRef<THREE.Mesh[]>([]);\n  const prevMouseRef = React.useRef({ x: 0, y: 0 });\n  const currentWaveRef = React.useRef(0);\n\n  const uniformsRef = React.useRef({\n    uDisplacement: { value: null as THREE.Texture | null },\n    uTexture: { value: null as THREE.Texture | null },\n    winResolution: { value: new THREE.Vector2(1, 1) },\n    uStrength: { value: distortionStrength },\n  });\n\n  const fboBase = useFBO(Math.max(width, 1), Math.max(height, 1), {\n    depthBuffer: false,\n    stencilBuffer: false,\n  });\n  const fboTexture = useFBO(Math.max(width, 1), Math.max(height, 1), {\n    depthBuffer: false,\n    stencilBuffer: false,\n  });\n\n  const imageCamera = React.useMemo(\n    () =>\n      new THREE.OrthographicCamera(\n        viewport.width / -2,\n        viewport.width / 2,\n        viewport.height / 2,\n        viewport.height / -2,\n        -1000,\n        1000,\n      ),\n    [viewport.height, viewport.width],\n  );\n\n  React.useEffect(() => {\n    imageCamera.position.z = 2;\n  }, [imageCamera]);\n\n  React.useEffect(() => {\n    uniformsRef.current.uStrength.value = distortionStrength;\n  }, [distortionStrength]);\n\n  React.useEffect(() => {\n    brushTexture.minFilter = THREE.LinearFilter;\n    brushTexture.magFilter = THREE.LinearFilter;\n    brushTexture.needsUpdate = true;\n  }, [brushTexture]);\n\n  React.useEffect(() => {\n    waveMeshesRef.current.forEach((mesh) => {\n      rippleScene.remove(mesh);\n      mesh.geometry.dispose();\n      if (Array.isArray(mesh.material)) {\n        mesh.material.forEach((material) => material.dispose());\n      } else {\n        mesh.material.dispose();\n      }\n    });\n\n    const meshes: THREE.Mesh[] = [];\n    for (let i = 0; i < waveCount; i += 1) {\n      const geometry = new THREE.PlaneGeometry(waveSize, waveSize, 1, 1);\n      const material = new THREE.MeshBasicMaterial({\n        transparent: true,\n        map: brushTexture,\n        depthWrite: false,\n      });\n      const mesh = new THREE.Mesh(geometry, material);\n      mesh.visible = false;\n      mesh.rotation.z = Math.random();\n      rippleScene.add(mesh);\n      meshes.push(mesh);\n    }\n\n    waveMeshesRef.current = meshes;\n    currentWaveRef.current = 0;\n\n    return () => {\n      meshes.forEach((mesh) => {\n        rippleScene.remove(mesh);\n        mesh.geometry.dispose();\n        if (Array.isArray(mesh.material)) {\n          mesh.material.forEach((material) => material.dispose());\n        } else {\n          mesh.material.dispose();\n        }\n      });\n    };\n  }, [brushTexture, rippleScene, waveCount, waveSize]);\n\n  React.useEffect(() => {\n    while (imageScene.children.length > 0) {\n      imageScene.remove(imageScene.children[0] as THREE.Object3D);\n    }\n\n    imageScene.add(imageCamera);\n    const geometry = new THREE.PlaneGeometry(1, 1);\n    const group = new THREE.Group();\n\n    images.forEach((item, index) => {\n      const texture = imageTextures[index];\n      if (!texture) {\n        return;\n      }\n      texture.minFilter = THREE.LinearFilter;\n      texture.magFilter = THREE.LinearFilter;\n      texture.needsUpdate = true;\n\n      const mesh = new THREE.Mesh(\n        geometry,\n        new THREE.MeshBasicMaterial({ map: texture }),\n      );\n      mesh.position.x = (item.x ?? (index - (images.length - 1) / 2) * 0.25) * viewport.width;\n      mesh.position.y = (item.y ?? 0) * viewport.height;\n      mesh.position.z = 1;\n      mesh.scale.x = viewport.width * (item.widthScale ?? 0.22);\n      mesh.scale.y = viewport.width * (item.heightScale ?? 0.28);\n      group.add(mesh);\n    });\n\n    imageScene.add(group);\n\n    return () => {\n      geometry.dispose();\n      group.children.forEach((child) => {\n        const mesh = child as THREE.Mesh;\n        mesh.geometry?.dispose?.();\n        if (Array.isArray(mesh.material)) {\n          mesh.material.forEach((material) => material.dispose());\n        } else {\n          mesh.material?.dispose?.();\n        }\n      });\n      imageScene.remove(group);\n    };\n  }, [imageCamera, imageScene, imageTextures, images, viewport.height, viewport.width]);\n\n  useFrame(() => {\n    const x = pointerRef.current.x - width / 2;\n    const y = -pointerRef.current.y + height / 2;\n    const prev = prevMouseRef.current;\n    const moved =\n      Math.abs(x - prev.x) > waveSpawnThreshold ||\n      Math.abs(y - prev.y) > waveSpawnThreshold;\n\n    if (moved && waveMeshesRef.current.length > 0) {\n      const waveIndex = currentWaveRef.current % waveMeshesRef.current.length;\n      const mesh = waveMeshesRef.current[waveIndex];\n      if (mesh) {\n        mesh.position.x = x;\n        mesh.position.y = y;\n        mesh.visible = true;\n        mesh.scale.set(1.75, 1.75, 1);\n        mesh.rotation.z = Math.random() * Math.PI;\n        const material = mesh.material as THREE.MeshBasicMaterial;\n        material.opacity = 1;\n      }\n      currentWaveRef.current = (waveIndex + 1) % waveMeshesRef.current.length;\n    }\n    prevMouseRef.current = { x, y };\n\n    waveMeshesRef.current.forEach((mesh) => {\n      if (!mesh.visible) {\n        return;\n      }\n      mesh.rotation.z += waveRotationSpeed;\n      mesh.scale.x = 0.98 * mesh.scale.x + waveGrowth;\n      mesh.scale.y = 0.98 * mesh.scale.y + waveGrowth;\n      const material = mesh.material as THREE.MeshBasicMaterial;\n      material.opacity *= waveFadeMultiplier;\n      if (material.opacity <= 0.01) {\n        mesh.visible = false;\n      }\n    });\n\n    uniformsRef.current.uTexture.value = fboTexture.texture;\n    uniformsRef.current.uDisplacement.value = fboBase.texture;\n    uniformsRef.current.winResolution.value\n      .set(width, height)\n      .multiplyScalar(pixelRatio);\n\n    gl.setRenderTarget(fboBase);\n    gl.clear();\n    gl.render(rippleScene, camera);\n\n    gl.setRenderTarget(fboTexture);\n    gl.clear();\n    gl.render(imageScene, imageCamera);\n\n    gl.setRenderTarget(null);\n  });\n\n  return (\n    <mesh>\n      <planeGeometry args={[Math.max(width, 1), Math.max(height, 1), 1, 1]} />\n      <shaderMaterial\n        vertexShader={vertexShader}\n        fragmentShader={fragmentShader}\n        transparent\n        uniforms={uniformsRef.current}\n      />\n    </mesh>\n  );\n}\n\nexport function ImageRippleEffect({\n  className,\n  images = DEFAULT_IMAGE_URLS.map((src) => ({ src })),\n  brushTextureUrl = BRUSH_DATA_URI,\n  distortionStrength = 0.075,\n  waveCount = 100,\n  waveSize = 60,\n  waveRotationSpeed = 0.025,\n  waveFadeMultiplier = 0.95,\n  waveGrowth = 0.155,\n  waveSpawnThreshold = 0.1,\n  children,\n  ...props\n}: ImageRippleEffectProps) {\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const { width, height, pixelRatio } = useContainerDimensions(containerRef);\n  const pointerRef = React.useRef({ x: 0, y: 0 });\n\n  const handlePointerMove = React.useCallback(\n    (event: React.PointerEvent<HTMLDivElement>) => {\n      const rect = containerRef.current?.getBoundingClientRect();\n      if (!rect) {\n        return;\n      }\n      pointerRef.current = {\n        x: event.clientX - rect.left,\n        y: event.clientY - rect.top,\n      };\n    },\n    [],\n  );\n\n  const frustumSize = height;\n  const aspect = width > 0 && height > 0 ? width / height : 1;\n\n  return (\n    <div\n      ref={containerRef}\n      onPointerMove={handlePointerMove}\n      className={cn(\n        \"relative h-[560px] w-full overflow-hidden text-white\",\n        className,\n      )}\n      {...props}\n    >\n      {width > 0 && height > 0 && (\n        <WebGLErrorBoundary fallback={<WebGLFallback className=\"absolute inset-0 h-full w-full\" />}>\n          <Canvas>\n            <OrthographicCamera\n              makeDefault\n              args={[\n                (frustumSize * aspect) / -2,\n                (frustumSize * aspect) / 2,\n                frustumSize / 2,\n                frustumSize / -2,\n                -1000,\n                1000,\n              ]}\n              position={[0, 0, 2]}\n            />\n            <RippleScene\n              width={width}\n              height={height}\n              pixelRatio={pixelRatio}\n              pointerRef={pointerRef}\n              images={images}\n              brushTextureUrl={brushTextureUrl}\n              distortionStrength={distortionStrength}\n              waveCount={waveCount}\n              waveSize={waveSize}\n              waveRotationSpeed={waveRotationSpeed}\n              waveFadeMultiplier={waveFadeMultiplier}\n              waveGrowth={waveGrowth}\n              waveSpawnThreshold={waveSpawnThreshold}\n            />\n          </Canvas>\n        </WebGLErrorBoundary>\n      )}\n      {children ? (\n        <div className=\"pointer-events-none absolute inset-0 z-10\">{children}</div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "components/ui/webgl-error-boundary.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport * as React from \"react\";\n\ninterface WebGLErrorBoundaryProps {\n  children: React.ReactNode;\n  fallback?: React.ReactNode;\n  onError?: (error: Error, errorInfo: React.ErrorInfo) => void;\n}\n\ninterface WebGLErrorBoundaryState {\n  hasError: boolean;\n}\n\nexport class WebGLErrorBoundary extends React.Component<\n  WebGLErrorBoundaryProps,\n  WebGLErrorBoundaryState\n> {\n  public state: WebGLErrorBoundaryState = { hasError: false };\n\n  static getDerivedStateFromError(): WebGLErrorBoundaryState {\n    return { hasError: true };\n  }\n\n  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {\n    this.props.onError?.(error, errorInfo);\n  }\n\n  render() {\n    if (this.state.hasError) {\n      return this.props.fallback ?? <WebGLFallback />;\n    }\n    return this.props.children;\n  }\n}\n\ninterface WebGLFallbackProps {\n  className?: string;\n  message?: string;\n}\n\nexport function WebGLFallback({\n  className,\n  message = \"Interactive WebGL content is unavailable on this device/browser.\",\n}: WebGLFallbackProps) {\n  return (\n    <div\n      className={cn(\n        \"flex h-full w-full items-center justify-center bg-gradient-to-br from-zinc-950 via-slate-900 to-zinc-900 px-4 text-center text-sm text-white/75\",\n        className,\n      )}\n      role=\"status\"\n      aria-live=\"polite\"\n    >\n      <p>{message}</p>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ]
}