{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ripple-transition",
  "type": "registry:ui",
  "title": "Ripple Transition",
  "dependencies": [
    "framer-motion"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "WebGL image transitions with noisy refractive waves, chromatic edges, glow, and click-triggered ripple origins.",
  "files": [
    {
      "path": "components/ui/ripple-transition.tsx",
      "content": "\"use client\";\n\nimport {\n  WebGLErrorBoundary,\n  WebGLFallback,\n} from \"./webgl-error-boundary\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  animate,\n  type AnimationPlaybackControls,\n  type Easing,\n} from \"framer-motion\";\nimport * as React from \"react\";\n\nexport interface RippleTransitionProps extends Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\" | \"onClick\"\n> {\n  images?: readonly string[];\n  duration?: number;\n  ease?: Easing;\n  autoPlay?: boolean;\n  autoPlayInterval?: number;\n  autoPlayOrigin?: \"center\" | \"random\";\n  waveSpeed?: number;\n  sigma?: number;\n  waveFreq?: number;\n  pushAmt?: number;\n  caStrength?: number;\n  glow?: number;\n  noiseWarp?: number;\n  pinch?: boolean;\n  borderRadius?: number;\n  background?: string;\n  label?: string;\n}\n\nconst sampleImages = [\n  \"https://images.unsplash.com/photo-1511497584788-876760111969?auto=format&fit=crop&q=85&w=1800\",\n  \"https://images.unsplash.com/photo-1473773508845-188df298d2d1?auto=format&fit=crop&q=85&w=1800\",\n  \"https://images.unsplash.com/photo-1502082553048-f009c37129b9?auto=format&fit=crop&q=85&w=1800\",\n] as const;\n\ntype RippleSettings = {\n  waveSpeed: number;\n  sigma: number;\n  waveFreq: number;\n  pushAmt: number;\n  caStrength: number;\n  glow: number;\n  noiseWarp: number;\n  pinch: boolean;\n};\n\ntype Picture = { element: HTMLImageElement; width: number; height: number };\n\nconst vertexSource = `\nattribute vec2 position;\nvarying vec2 uv;\nvoid main() {\n  uv = vec2(position.x * .5 + .5, .5 - position.y * .5);\n  gl_Position = vec4(position, 0., 1.);\n}`;\n\nconst fragmentSource = `\nprecision highp float;\nvarying vec2 uv;\nuniform sampler2D fromImage;\nuniform sampler2D toImage;\nuniform vec2 viewport;\nuniform vec2 fromSize;\nuniform vec2 toSize;\nuniform vec2 origin;\nuniform float phase;\nuniform float speed;\nuniform float thickness;\nuniform float frequency;\nuniform float displacement;\nuniform float chroma;\nuniform float highlight;\nuniform float roughness;\nuniform float pinchAmount;\n\nfloat random2(vec2 p) {\n  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);\n}\n\nfloat smoothNoise(vec2 p) {\n  vec2 cell = floor(p);\n  vec2 local = fract(p);\n  local = local * local * (3. - 2. * local);\n  return mix(\n    mix(random2(cell), random2(cell + vec2(1., 0.)), local.x),\n    mix(random2(cell + vec2(0., 1.)), random2(cell + vec2(1.)), local.x),\n    local.y\n  );\n}\n\nfloat layeredNoise(vec2 p) {\n  return smoothNoise(p) * .57 +\n    smoothNoise(p * 2.07 + 4.3) * .29 +\n    smoothNoise(p * 4.21 - 2.8) * .14;\n}\n\nvec2 cover(vec2 point, vec2 media, vec2 frame) {\n  float mediaRatio = media.x / media.y;\n  float frameRatio = frame.x / frame.y;\n  vec2 crop = frameRatio > mediaRatio\n    ? vec2(1., mediaRatio / frameRatio)\n    : vec2(frameRatio / mediaRatio, 1.);\n  return (point - .5) * crop + .5;\n}\n\nvec3 splitSample(sampler2D image, vec2 size, vec2 point, vec2 split) {\n  return vec3(\n    texture2D(image, cover(point - split, size, viewport)).r,\n    texture2D(image, cover(point, size, viewport)).g,\n    texture2D(image, cover(point + split, size, viewport)).b\n  );\n}\n\nvoid main() {\n  float aspect = viewport.x / viewport.y;\n  vec2 radial = uv - origin;\n  radial.x *= aspect;\n  float distanceFromOrigin = length(radial);\n  float farthest = length(vec2(aspect, 1.));\n  float distance01 = distanceFromOrigin * 2. / farthest;\n  float agitation = (layeredNoise(radial * 9. + phase * 1.8) - .5) * roughness;\n  float boundary = phase * speed;\n  float signedDistance = distance01 + agitation - boundary;\n  float wave = exp(-(signedDistance * signedDistance) / max(.0001, 2. * thickness * thickness));\n  wave *= .55 + .45 * cos(signedDistance * frequency * 6.28318);\n  wave *= smoothstep(0., .08, phase) * (1. - smoothstep(.82, 1., phase));\n\n  vec2 direction = distanceFromOrigin > .0001 ? radial / distanceFromOrigin : vec2(0.);\n  direction.x /= aspect;\n  float centerPull = exp(-distanceFromOrigin * distanceFromOrigin * 45.) * pinchAmount;\n  vec2 offset = direction * (wave * displacement - centerPull * .018);\n  vec2 split = direction * wave * chroma;\n  float reveal = 1. - smoothstep(-.045, .045, signedDistance);\n\n  vec3 before = splitSample(fromImage, fromSize, uv - offset, split);\n  vec3 after = splitSample(toImage, toSize, uv - offset, split);\n  vec3 color = mix(before, after, reveal);\n  color += wave * highlight * .38;\n  color *= 1. - centerPull * .12;\n  gl_FragColor = vec4(clamp(color, 0., 1.), 1.);\n}`;\n\nfunction fetchPictures(sources: readonly string[], signal: AbortSignal) {\n  return Promise.all(\n    sources.map(\n      (source) =>\n        new Promise<Picture | null>((resolve) => {\n          const image = new Image();\n          const finish = (picture: Picture | null) => {\n            image.onload = null;\n            image.onerror = null;\n            resolve(picture);\n          };\n          image.crossOrigin = \"anonymous\";\n          image.onload = () =>\n            finish({\n              element: image,\n              width: image.naturalWidth || 1,\n              height: image.naturalHeight || 1,\n            });\n          image.onerror = () => finish(null);\n          signal.addEventListener(\"abort\", () => finish(null), { once: true });\n          image.src = source;\n        }),\n    ),\n  ).then((pictures) =>\n    pictures.filter((picture): picture is Picture => picture !== null),\n  );\n}\n\nfunction shader(gl: WebGLRenderingContext, type: number, source: string) {\n  const result = gl.createShader(type);\n  if (!result) throw new Error(\"Unable to allocate a WebGL shader.\");\n  gl.shaderSource(result, source);\n  gl.compileShader(result);\n  if (!gl.getShaderParameter(result, gl.COMPILE_STATUS)) {\n    const message = gl.getShaderInfoLog(result) ?? \"Shader compilation failed.\";\n    gl.deleteShader(result);\n    throw new Error(message);\n  }\n  return result;\n}\n\nclass RippleRenderer {\n  private gl: WebGLRenderingContext;\n  private program: WebGLProgram;\n  private vertexShader: WebGLShader;\n  private fragmentShader: WebGLShader;\n  private geometry: WebGLBuffer;\n  private textures: [WebGLTexture, WebGLTexture];\n  private textureSlots: [number, number] = [0, 1];\n  private phase = 0;\n  private origin: [number, number] = [0.5, 0.5];\n  private settings: RippleSettings;\n  private sizes: [Picture, Picture];\n\n  constructor(\n    private canvas: HTMLCanvasElement,\n    pictures: Picture[],\n    settings: RippleSettings,\n  ) {\n    const gl = canvas.getContext(\"webgl\", {\n      antialias: true,\n      premultipliedAlpha: false,\n    });\n    if (!gl) throw new Error(\"WebGL is not supported.\");\n    this.gl = gl;\n    this.settings = settings;\n    this.sizes = [pictures[0]!, pictures[1] ?? pictures[0]!];\n    this.vertexShader = shader(gl, gl.VERTEX_SHADER, vertexSource);\n    this.fragmentShader = shader(gl, gl.FRAGMENT_SHADER, fragmentSource);\n    const program = gl.createProgram();\n    if (!program) throw new Error(\"Unable to allocate a WebGL program.\");\n    this.program = program;\n    gl.attachShader(program, this.vertexShader);\n    gl.attachShader(program, this.fragmentShader);\n    gl.linkProgram(program);\n    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n      throw new Error(\n        gl.getProgramInfoLog(program) ?? \"WebGL program link failed.\",\n      );\n    }\n    gl.useProgram(program);\n\n    const geometry = gl.createBuffer();\n    if (!geometry) throw new Error(\"Unable to allocate WebGL geometry.\");\n    this.geometry = geometry;\n    gl.bindBuffer(gl.ARRAY_BUFFER, geometry);\n    gl.bufferData(\n      gl.ARRAY_BUFFER,\n      new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),\n      gl.STATIC_DRAW,\n    );\n    const position = gl.getAttribLocation(program, \"position\");\n    gl.enableVertexAttribArray(position);\n    gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);\n    this.textures = [\n      this.makeTexture(0, this.sizes[0].element),\n      this.makeTexture(1, this.sizes[1].element),\n    ];\n    gl.uniform1i(this.location(\"fromImage\"), 0);\n    gl.uniform1i(this.location(\"toImage\"), 1);\n  }\n\n  private location(name: string) {\n    return this.gl.getUniformLocation(this.program, name);\n  }\n\n  private makeTexture(unit: number, image: HTMLImageElement) {\n    const gl = this.gl;\n    const texture = gl.createTexture();\n    if (!texture) throw new Error(\"Unable to allocate a WebGL texture.\");\n    gl.activeTexture(gl.TEXTURE0 + unit);\n    gl.bindTexture(gl.TEXTURE_2D, texture);\n    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);\n    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n    return texture;\n  }\n\n  resize(width: number, height: number) {\n    const density = Math.min(2, window.devicePixelRatio || 1);\n    const pixelWidth = Math.max(1, Math.round(width * density));\n    const pixelHeight = Math.max(1, Math.round(height * density));\n    if (\n      this.canvas.width !== pixelWidth ||\n      this.canvas.height !== pixelHeight\n    ) {\n      this.canvas.width = pixelWidth;\n      this.canvas.height = pixelHeight;\n      this.gl.viewport(0, 0, pixelWidth, pixelHeight);\n    }\n    this.draw();\n  }\n\n  configure(settings: RippleSettings) {\n    this.settings = settings;\n    this.draw();\n  }\n\n  begin(x: number, y: number) {\n    this.origin = [x, y];\n    this.phase = 0;\n    this.draw();\n  }\n\n  setPhase(value: number) {\n    this.phase = value;\n    this.draw();\n  }\n\n  advance(picture: Picture, nextIndex: number) {\n    const gl = this.gl;\n    const previousToSlot = this.textureSlots[1];\n    this.textureSlots = [previousToSlot, nextIndex];\n    this.sizes = [this.sizes[1], picture];\n    gl.activeTexture(gl.TEXTURE0 + nextIndex);\n    gl.bindTexture(gl.TEXTURE_2D, this.textures[nextIndex]!);\n    gl.texImage2D(\n      gl.TEXTURE_2D,\n      0,\n      gl.RGBA,\n      gl.RGBA,\n      gl.UNSIGNED_BYTE,\n      picture.element,\n    );\n    gl.uniform1i(this.location(\"fromImage\"), previousToSlot);\n    gl.uniform1i(this.location(\"toImage\"), nextIndex);\n    this.phase = 0;\n    this.draw();\n  }\n\n  draw() {\n    const gl = this.gl;\n    const s = this.settings;\n    gl.useProgram(this.program);\n    gl.uniform2f(\n      this.location(\"viewport\"),\n      this.canvas.width,\n      this.canvas.height,\n    );\n    gl.uniform2f(\n      this.location(\"fromSize\"),\n      this.sizes[0].width,\n      this.sizes[0].height,\n    );\n    gl.uniform2f(\n      this.location(\"toSize\"),\n      this.sizes[1].width,\n      this.sizes[1].height,\n    );\n    gl.uniform2f(this.location(\"origin\"), this.origin[0], this.origin[1]);\n    gl.uniform1f(this.location(\"phase\"), this.phase);\n    gl.uniform1f(this.location(\"speed\"), s.waveSpeed);\n    gl.uniform1f(this.location(\"thickness\"), s.sigma);\n    gl.uniform1f(this.location(\"frequency\"), s.waveFreq);\n    gl.uniform1f(this.location(\"displacement\"), s.pushAmt);\n    gl.uniform1f(this.location(\"chroma\"), s.caStrength);\n    gl.uniform1f(this.location(\"highlight\"), s.glow);\n    gl.uniform1f(this.location(\"roughness\"), s.noiseWarp * 0.09);\n    gl.uniform1f(this.location(\"pinchAmount\"), s.pinch ? 1 : 0);\n    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);\n  }\n\n  destroy() {\n    const gl = this.gl;\n    this.textures.forEach((texture) => gl.deleteTexture(texture));\n    gl.deleteBuffer(this.geometry);\n    gl.deleteProgram(this.program);\n    gl.deleteShader(this.vertexShader);\n    gl.deleteShader(this.fragmentShader);\n  }\n}\n\nfunction RippleTransitionCanvas({\n  images = sampleImages,\n  duration = 1.4,\n  ease = \"easeInOut\",\n  autoPlay = false,\n  autoPlayInterval = 3200,\n  autoPlayOrigin = \"center\",\n  waveSpeed = 1.6,\n  sigma = 0.15,\n  waveFreq = 5,\n  pushAmt = 0.145,\n  caStrength = 0.02,\n  glow = 0.73,\n  noiseWarp = 1,\n  pinch = false,\n  borderRadius = 24,\n  background = \"#111416\",\n  label = \"Ripple image transition\",\n  className,\n  style,\n  ...props\n}: RippleTransitionProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const engineRef = React.useRef<RippleRenderer | null>(null);\n  const playRef = React.useRef<(x?: number, y?: number) => void>(\n    () => undefined,\n  );\n  const settings = React.useMemo<RippleSettings>(\n    () => ({\n      waveSpeed,\n      sigma,\n      waveFreq,\n      pushAmt,\n      caStrength,\n      glow,\n      noiseWarp,\n      pinch,\n    }),\n    [caStrength, glow, noiseWarp, pinch, pushAmt, sigma, waveFreq, waveSpeed],\n  );\n  const settingsRef = React.useRef(settings);\n  settingsRef.current = settings;\n\n  React.useEffect(() => {\n    engineRef.current?.configure(settings);\n  }, [settings]);\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const abortController = new AbortController();\n    let animation: AnimationPlaybackControls | null = null;\n    let engine: RippleRenderer | null = null;\n    let busy = false;\n    let current = 0;\n\n    fetchPictures(images, abortController.signal).then((pictures) => {\n      if (abortController.signal.aborted || pictures.length === 0) return;\n      engine = new RippleRenderer(canvas, pictures, settingsRef.current);\n      engineRef.current = engine;\n      const observer = new ResizeObserver(([entry]) => {\n        const box = entry?.contentRect;\n        if (box) engine?.resize(box.width, box.height);\n      });\n      observer.observe(root);\n      engine.resize(root.clientWidth, root.clientHeight);\n\n      playRef.current = (x = 0.5, y = 0.5) => {\n        if (busy || pictures.length < 2 || !engine) return;\n        busy = true;\n        engine.begin(x, y);\n        animation?.stop();\n        animation = animate(0, 1, {\n          duration,\n          ease,\n          onUpdate: (value) => engine?.setPhase(value),\n          onComplete: () => {\n            current = (current + 1) % pictures.length;\n            const following = pictures[(current + 1) % pictures.length]!;\n            engine?.advance(following, (current + 1) % 2);\n            busy = false;\n          },\n        });\n      };\n\n      abortController.signal.addEventListener(\n        \"abort\",\n        () => {\n          observer.disconnect();\n          animation?.stop();\n          engine?.destroy();\n        },\n        { once: true },\n      );\n    });\n\n    return () => {\n      playRef.current = () => undefined;\n      engineRef.current = null;\n      abortController.abort();\n    };\n  }, [duration, ease, images]);\n\n  React.useEffect(() => {\n    if (!autoPlay) return;\n    const timer = window.setInterval(() => {\n      const x = autoPlayOrigin === \"random\" ? 0.18 + Math.random() * 0.64 : 0.5;\n      const y = autoPlayOrigin === \"random\" ? 0.18 + Math.random() * 0.64 : 0.5;\n      playRef.current(x, y);\n    }, autoPlayInterval);\n    return () => window.clearInterval(timer);\n  }, [autoPlay, autoPlayInterval, autoPlayOrigin]);\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        \"relative h-full min-h-[320px] w-full cursor-pointer overflow-hidden leading-none outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2\",\n        className,\n      )}\n      style={{\n        borderRadius,\n        background,\n        touchAction: \"manipulation\",\n        ...style,\n      }}\n      role=\"button\"\n      tabIndex={0}\n      aria-label={label}\n      onPointerUp={(event) => {\n        const bounds = event.currentTarget.getBoundingClientRect();\n        playRef.current(\n          (event.clientX - bounds.left) / bounds.width,\n          (event.clientY - bounds.top) / bounds.height,\n        );\n      }}\n      onKeyDown={(event) => {\n        if (event.key === \"Enter\" || event.key === \" \") {\n          event.preventDefault();\n          playRef.current();\n        }\n      }}\n      {...props}\n    >\n      <canvas ref={canvasRef} className=\"absolute inset-0 h-full w-full\" />\n    </div>\n  );\n}\n\nexport function RippleTransition(props: RippleTransitionProps) {\n  return (\n    <WebGLErrorBoundary\n      fallback={\n        <WebGLFallback\n          className={props.className}\n          message=\"Ripple transitions need WebGL, which is unavailable in this browser.\"\n        />\n      }\n    >\n      <RippleTransitionCanvas {...props} />\n    </WebGLErrorBoundary>\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"
    }
  ]
}