Subframe
WebGL & shadersWater ripple image effect

Wake image

A deep-blue sea photograph bends under short-lived wave fronts left by a moving pointer.

Move across the water and adjust the wake strength.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Download images (1)

Save these files in your static assets folder at /motion/assets/, or update their paths in the component.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 *
 * Copyright (c) 2026 Subframe
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

"use client"

import { useEffect, useRef, useState } from "react"

function useReduced() {
  const [reduced, setReduced] = useState(false)
  useEffect(() => {
    const q = matchMedia("(prefers-reduced-motion: reduce)")
    const sync = () => setReduced(q.matches)
    sync()
    q.addEventListener("change", sync)
    return () => q.removeEventListener("change", sync)
  }, [])
  return reduced
}

const fragment = `precision highp float;varying vec2 uv;uniform sampler2D photo;uniform vec2 size;uniform float imageAspect;uniform float now;uniform float strength;uniform vec4 marks[12];
vec2 cover(vec2 p){float a=size.x/size.y;return (p-.5)*vec2(min(1.,a/imageAspect),min(1.,imageAspect/a))+.5;}
void main(){vec2 p=uv;float aspect=size.x/size.y;vec2 d=vec2(0.);for(int i=0;i<12;i++){float age=now-marks[i].z;if(marks[i].w>0.&&age>=0.&&age<2.6){vec2 delta=(p-marks[i].xy)*vec2(aspect,1.);float r=length(delta);float front=age*.16;float band=exp(-pow((r-front)/(.035+age*.025),2.));float wave=sin((r-front)*85.)*band*exp(-age*1.9)*marks[i].w;d+=normalize(delta+vec2(.0001))*wave*.012/vec2(aspect,1.);}}vec2 q=cover(p+d*strength);vec3 c=texture2D(photo,q).rgb;gl_FragColor=vec4(c,1.);}`
export default function WakeImage({ active = true }: { active?: boolean }) {
  const canvas = useRef<HTMLCanvasElement>(null),
    [failed, setFailed] = useState(false),
    [strength, setStrength] = useState(0.8),
    reduced = useReduced(),
    live = useRef({ active, reduced, strength }),
    control = useRef<{ clear: () => void; wake: () => void; stop: () => void } | null>(null)
  live.current = { active, reduced, strength }
  useEffect(() => {
    const c = canvas.current!,
      gl = c.getContext("webgl", { alpha: false, antialias: false })
    if (!gl) {
      setFailed(true)
      return
    }
    let dead = false,
      raf = 0,
      last = 0,
      time = 0,
      loaded = false,
      w = 1,
      h = 1,
      head = 0,
      lastPointer = 0,
      energy = 0
    const marks = new Float32Array(48)
    const shaders: WebGLShader[] = []
    const compile = (type: number, code: string) => {
      const shader = gl.createShader(type)!
      gl.shaderSource(shader, code)
      gl.compileShader(shader)
      shaders.push(shader)
      if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw Error(gl.getShaderInfoLog(shader) || "Shader failed")
      return shader
    }
    const program = gl.createProgram()!
    let buffer: WebGLBuffer, texture: WebGLTexture
    try {
      gl.attachShader(
        program,
        compile(
          gl.VERTEX_SHADER,
          "attribute vec2 p;varying vec2 uv;void main(){uv=p*.5+.5;gl_Position=vec4(p,0.,1.);}",
        ),
      )
      gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragment))
      gl.linkProgram(program)
      if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error("Link failed")
      gl.useProgram(program)
      buffer = gl.createBuffer()!
      gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW)
      const pos = gl.getAttribLocation(program, "p")
      gl.enableVertexAttribArray(pos)
      gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0)
      texture = gl.createTexture()!
      gl.bindTexture(gl.TEXTURE_2D, texture)
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
    } catch {
      gl.deleteProgram(program)
      shaders.forEach((s) => gl.deleteShader(s))
      setFailed(true)
      return
    }
    const u = (name: string) => gl.getUniformLocation(program, name),
      loc = { size: u("size"), aspect: u("imageAspect"), now: u("now"), strength: u("strength"), marks: u("marks[0]") }
    const image = new Image()
    const paint = () => {
      if (!loaded || dead || gl.isContextLost()) return
      gl.viewport(0, 0, c.width, c.height)
      gl.uniform2f(loc.size, w, h)
      gl.uniform1f(loc.aspect, image.width / image.height)
      gl.uniform1f(loc.now, time)
      gl.uniform1f(loc.strength, live.current.reduced ? 0 : live.current.strength)
      gl.uniform4fv(loc.marks, marks)
      gl.drawArrays(gl.TRIANGLES, 0, 6)
    }
    const size = () => {
      const r = c.getBoundingClientRect()
      w = r.width
      h = r.height
      const d = Math.min(devicePixelRatio || 1, 2, Math.sqrt(3600000 / Math.max(1, r.width * r.height)))
      const nextWidth = Math.max(1, Math.round(w * d))
      const nextHeight = Math.max(1, Math.round(h * d))
      if (c.width !== nextWidth || c.height !== nextHeight) {
        c.width = nextWidth
        c.height = nextHeight
      }
      paint()
    }
    const tick = (t: number) => {
      raf = 0
      if (!live.current.active || live.current.reduced || document.hidden || gl.isContextLost()) return
      const dt = Math.min(0.04, (t - last) / 1000 || 0.016)
      last = t
      time += dt
      energy -= dt
      paint()
      if (energy > 0) raf = requestAnimationFrame(tick)
    }
    const wake = () => {
      paint()
      if (
        !raf &&
        energy > 0 &&
        live.current.active &&
        !live.current.reduced &&
        !document.hidden &&
        !gl.isContextLost()
      ) {
        last = 0
        raf = requestAnimationFrame(tick)
      }
    }
    const stop = () => {
      cancelAnimationFrame(raf)
      raf = 0
      last = 0
    }
    const move = (e: PointerEvent) => {
      if (live.current.reduced || !loaded || (e.pointerType !== "mouse" && !e.buttons)) return
      const t = performance.now()
      if (t - lastPointer < 18) return
      lastPointer = t
      const r = c.getBoundingClientRect(),
        i = head++ % 12
      marks[i * 4] = (e.clientX - r.left) / r.width
      marks[i * 4 + 1] = 1 - (e.clientY - r.top) / r.height
      marks[i * 4 + 2] = time
      marks[i * 4 + 3] = 1
      energy = 2.7
      wake()
    }
    const lost = (e: Event) => {
      e.preventDefault()
      stop()
      setFailed(true)
    }
    image.onload = () => {
      if (dead) return
      gl.bindTexture(gl.TEXTURE_2D, texture)
      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
      loaded = true
      size()
    }
    image.onerror = () => {
      if (!dead) setFailed(true)
    }
    image.src = "/motion/assets/coastal-break.webp"
    const ro = new ResizeObserver(size)
    const transforms = new MutationObserver(size)
    for (let node: HTMLElement | null = c; node; node = node.parentElement) {
      ro.observe(node)
      transforms.observe(node, { attributes: true, attributeFilter: ["style", "class"] })
    }
    window.addEventListener("resize", size)
    size()
    c.addEventListener("pointermove", move)
    c.addEventListener("pointerdown", move)
    c.addEventListener("webglcontextlost", lost)
    const visibility = () => (document.hidden ? stop() : wake())
    document.addEventListener("visibilitychange", visibility)
    control.current = {
      clear: () => {
        marks.fill(0)
        energy = 0
        stop()
        paint()
      },
      wake,
      stop,
    }
    return () => {
      dead = true
      control.current = null
      stop()
      ro.disconnect()
      transforms.disconnect()
      window.removeEventListener("resize", size)
      c.removeEventListener("pointermove", move)
      c.removeEventListener("pointerdown", move)
      c.removeEventListener("webglcontextlost", lost)
      document.removeEventListener("visibilitychange", visibility)
      gl.deleteTexture(texture)
      gl.deleteBuffer(buffer)
      gl.deleteProgram(program)
      shaders.forEach((s) => gl.deleteShader(s))
    }
  }, [])
  useEffect(() => {
    if (active && !reduced) control.current?.wake()
    else {
      control.current?.stop()
      control.current?.wake()
    }
  }, [active, reduced, strength])
  return (
    <section className="sf-wake-image" inert={!active}>
      <img src="/motion/assets/coastal-break.webp" alt="Turquoise waves crossing a dark rocky coastline" />
      <canvas ref={canvas} aria-hidden="true" style={{ visibility: failed ? "hidden" : "visible" }} />
      <header>
        <b>LOW TIDE</b>
        <span>Field notes</span>
      </header>
      <footer>
        <label>
          Wake
          <input
            aria-label="Wake intensity"
            type="range"
            min="0"
            max="1.5"
            step=".05"
            value={strength}
            onChange={(e) => setStrength(Number(e.target.value))}
          />
        </label>
        <button type="button" onClick={() => control.current?.clear()}>
          Clear
        </button>
      </footer>
      <style>{`.sf-wake-image{width:100%;height:100%;position:relative;background:#007f8f;overflow:hidden;font-family:Arial,sans-serif;color:#fff}.sf-wake-image>img,.sf-wake-image canvas{position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:cover}.sf-wake-image canvas{touch-action:pan-y;cursor:crosshair}.sf-wake-image header{position:absolute;left:20px;right:20px;top:18px;display:flex;align-items:center;justify-content:space-between;pointer-events:none;text-shadow:0 1px 8px #00393f88;font-size:10px}.sf-wake-image header b{font-size:11px;letter-spacing:.16em}.sf-wake-image footer{position:absolute;left:18px;right:18px;bottom:14px;display:flex;justify-content:space-between;align-items:center;gap:24px;font-size:10px;text-shadow:0 1px 8px #00393f88}.sf-wake-image label{display:flex;align-items:center;gap:10px}.sf-wake-image input{width:90px;height:30px;accent-color:white}.sf-wake-image button{position:relative;border:1px solid #ffffff66;border-radius:18px;color:white;background:#053c4470;font:10px Arial;height:32px;min-width:60px;cursor:pointer}.sf-wake-image button:before{content:'';position:absolute;inset:-6px}.sf-wake-image button:focus-visible,.sf-wake-image input:focus-visible{outline:2px solid white;outline-offset:3px}`}</style>
    </section>
  )
}

Where it works well

Add a local refractive wake to a still image without a simulation package.

Reduced motion

The photograph remains still; the distortion strength resolves to zero.

Things to know

Twelve analytic pointer marks form the wake; this is not a fluid solver. WebGL 1 is optional, with the original photograph retained underneath.

More examples