All components

Water Ripple Text Simulation

WebGL animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A full-screen Three.js water-ripple simulation over giant canvas-drawn wordmark text: moving the pointer injects pressure into double-buffered FloatType render targets running a ping-pong wave-equation shader, and a render pass refracts and adds specular highlights to the text beneath, so the cursor leaves rippling wakes across the surface. Driven entirely by mousemove on the WebGL canvas (no GSAP).

How it's built

Category3d-webgl
Techthree
Complexitysection
Performance costheavy
Mobile-safedesktop-first

water-simulation ripple webgl three shaders wave-equation mouse canvas hero

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Water Ripple Text Simulation — build prompt

Goal

Build a full-screen hero for a fictional product studio called "Soft Horizon". The whole viewport is a WebGL water surface: a giant canvas-drawn wordmark ("softhorizon") sits under a real-time wave simulation, and as the pointer moves across the screen the cursor injects pressure into the fluid, leaving rippling wakes that refract the text and throw off bright specular glints, like poking the surface of a still pool. Nav and footer text float above the water. The star effect is the physics itself: a double-buffered (ping-pong) GPU wave-equation solver whose gradient field distorts and lights the text every frame. No GSAP, no scroll — everything is driven by mousemove on the canvas plus a requestAnimationFrame loop.

Tech

Vanilla HTML/CSS/JS with ES module imports. The only runtime dependency is three (npm), imported as import * as THREE from "three". There is no GSAP, no Lenis, no scroll library. Assume a fresh Vite project with three installed via npm.

Split the code into four files:

  • index.html
  • styles.css
  • script.js — the Three.js app (<script type="module" src="./script.js">), wrapped in a DOMContentLoaded listener.
  • shaders.js — a small module that exports four GLSL strings: simulationVertexShader, simulationFragmentShader, renderVertexShader, renderFragmentShader. Imported by script.js.

Layout / HTML

Two fixed UI layers over the canvas; the <canvas> is created and appended to <body> by JS at runtime.

nav
  .logo > p            → "Soft Horizon"
  .nav-items
    p ×3               → "Product", "Concept", "Partners"
    button             → "Try now"
footer
  h1                   → "Expanding perspectives with serene and boundless possibilities"
  .footer-links
    p ×2               → "Sign Up", "Log In"

Styling

  • Font: a bold neo-grotesque sans-serif. The original uses "Test Söhne"; any similar grotesque (Söhne / Helvetica Neue / Inter / Arial) is fine as long as a bold weight is available for the canvas text. body { font-family: "Test Söhne", sans-serif; }.
  • Colors — only two, and they are load-bearing (the canvas reuses the exact same hexes so the WebGL surface blends into the page):
  • Background orange #fb7427.
  • Text / foreground pale cream-yellow #fef4b8.
  • Global reset: * { margin:0; padding:0; box-sizing:border-box; }.
  • body: width:100%; height:100%; background:#fb7427; color:#fef4b8;
  • h1: font-size:36px; font-weight:400; line-height:1.25;
  • p: font-size:15px; line-height:1.25;
  • nav: position:fixed; top:0; left:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; z-index:2;
  • .nav-items: display:flex; align-items:center; gap:2em;
  • button: outline:none; border:2px solid #fef4b8; border-radius:2em; color:#fef4b8; background:transparent; font-family:"Test Söhne"; font-size:15px; padding:0.5em 1em;
  • footer: position:fixed; bottom:0; left:0; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:flex-end; z-index:2;
  • footer h1: width:40%;
  • .footer-links: display:flex; gap:2em;
  • canvas: position:fixed; top:0; left:0; width:100vw; height:100vh; (it sits at the default z-index, below the z-index:2 nav/footer).

The effect (be exhaustive — this is the whole component)

The technique is a ping-pong wave simulation: each frame, a simulation shader reads the previous water state from render target A and writes the new state into render target B; then a render shader samples B to refract and light a text texture onto the screen; then A and B are swapped. The water state is stored in an RGBA float texture where the four channels mean: R = pressure (height), G = pressure velocity, B = ∂pressure/∂x (x gradient), A = ∂pressure/∂y (y gradient).

Renderer / scenes / camera
  • Two scenes: scene (final on-screen pass) and simScene (off-screen simulation pass).
  • camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) — maps a [-1,1] quad to the full viewport (shared by both passes).
  • renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true }).
  • renderer.outputColorSpace = THREE.LinearSRGBColorSpace; — critical. Disable sRGB output encoding so shader output is written to the framebuffer verbatim and the #fb7427 / #fef4b8 colors match the CSS exactly (mirrors the old three r128 pipeline). Without this the orange/cream will look washed out.
  • renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  • renderer.setSize(window.innerWidth, window.innerHeight);
  • Append renderer.domElement to document.body.
  • const mouse = new THREE.Vector2(); and let frame = 0;
Render targets (the double buffer)
  • Compute simulation resolution at full device pixel ratio (not the capped renderer ratio):

const width = window.innerWidth * window.devicePixelRatio; const height = window.innerHeight * window.devicePixelRatio;

  • Options: { format: THREE.RGBAFormat, type: THREE.FloatType, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, stencilBuffer: false, depthBuffer: false }. FloatType is required — the wave state needs full-precision floats.
  • let rtA = new THREE.WebGLRenderTarget(width, height, options);

let rtB = new THREE.WebGLRenderTarget(width, height, options); (declared with let so they can be swapped).

Materials & quads
  • simMaterial = new THREE.ShaderMaterial({ uniforms, vertexShader: simulationVertexShader, fragmentShader: simulationFragmentShader }) with uniforms:
  • textureA: { value: null } (the previous water state)
  • mouse: { value: mouse } (the shared Vector2, in device pixels)
  • resolution: { value: new THREE.Vector2(width, height) }
  • time: { value: 0 }
  • frame: { value: 0 }
  • renderMaterial = new THREE.ShaderMaterial({ uniforms, vertexShader: renderVertexShader, fragmentShader: renderFragmentShader, transparent: true }) with uniforms:
  • textureA: { value: null } (new water state)
  • textureB: { value: null } (the text texture)
  • const plane = new THREE.PlaneGeometry(2, 2);
  • simQuad = new THREE.Mesh(plane, simMaterial);simScene.add(simQuad);
  • renderQuad = new THREE.Mesh(plane, renderMaterial);scene.add(renderQuad);
The text texture (canvas-drawn wordmark)

Draw the wordmark once into a 2D canvas and wrap it as a CanvasTexture — this is the image the water refracts (there are no image files).

  • Create a <canvas> sized width × height (the DPR-scaled sim size), get ctx = canvas.getContext("2d", { alpha: true }).
  • Fill the whole canvas with the orange background first: ctx.fillStyle = "#fb7427"; ctx.fillRect(0, 0, width, height);.
  • const fontSize = Math.round(250 * window.devicePixelRatio);
  • ctx.fillStyle = "#fef4b8"; (cream text)
  • ctx.font = \bold ${fontSize}px Test Söhne\;
  • ctx.textAlign = "center"; ctx.textBaseline = "middle";
  • ctx.textRendering = "geometricPrecision"; ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high";
  • ctx.fillText("softhorizon", width / 2, height / 2); — one lowercase word, centered, huge.
  • const textTexture = new THREE.CanvasTexture(canvas); then textTexture.minFilter = THREE.LinearFilter; textTexture.magFilter = THREE.LinearFilter; textTexture.format = THREE.RGBAFormat;
Pointer input (on renderer.domElement)
  • mousemove: convert to device pixels with a Y flip (WebGL origin is bottom-left):

mouse.x = e.clientX * window.devicePixelRatio; mouse.y = (window.innerHeight - e.clientY) * window.devicePixelRatio;

  • mouseleave: mouse.set(0, 0); — parks the pointer at the origin, which disables ripple injection (the sim gates on mouse.x > 0).
The render loop (ping-pong — reproduce this order exactly)

const animate = () => { ... requestAnimationFrame(animate); } called once. Each frame:

  1. simMaterial.uniforms.frame.value = frame++; (post-increment — first frame passes 0).
  2. simMaterial.uniforms.time.value = performance.now() / 1000;
  3. Simulation pass: simMaterial.uniforms.textureA.value = rtA.texture;renderer.setRenderTarget(rtB); renderer.render(simScene, camera); (reads A, writes the new state into B).
  4. Screen pass: renderMaterial.uniforms.textureA.value = rtB.texture; renderMaterial.uniforms.textureB.value = textTexture;renderer.setRenderTarget(null); renderer.render(scene, camera); (samples the fresh state B + the text, draws to the canvas).
  5. Swap: const temp = rtA; rtA = rtB; rtB = temp; (B becomes the "previous" for next frame).
Simulation vertex & render vertex shaders (identical pass-throughs)
varying vec2 vUv;
void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
Simulation fragment shader (the wave equation — reproduce the math exactly)

Uniforms: sampler2D textureA, vec2 mouse, vec2 resolution, float time, int frame; varying vec2 vUv. Constant: const float delta = 1.4; (the simulation timestep).

vec2 uv = vUv;
if (frame == 0) { gl_FragColor = vec4(0.0); return; }   // clear the buffer on the very first frame

vec4 data = texture2D(textureA, uv);
float pressure = data.x;
float pVel     = data.y;

vec2 texelSize = 1.0 / resolution;
float p_right = texture2D(textureA, uv + vec2( texelSize.x, 0.0)).x;
float p_left  = texture2D(textureA, uv + vec2(-texelSize.x, 0.0)).x;
float p_up    = texture2D(textureA, uv + vec2(0.0,  texelSize.y)).x;
float p_down  = texture2D(textureA, uv + vec2(0.0, -texelSize.y)).x;

// Neumann (reflective) boundaries: mirror the neighbor at each edge
if (uv.x <= texelSize.x)        p_left  = p_right;
if (uv.x >= 1.0 - texelSize.x)  p_right = p_left;
if (uv.y <= texelSize.y)        p_down  = p_up;
if (uv.y >= 1.0 - texelSize.y)  p_up    = p_down;

// Laplacian → acceleration on the pressure-velocity (split into x and y halves)
pVel += delta * (-2.0 * pressure + p_right + p_left) / 4.0;
pVel += delta * (-2.0 * pressure + p_up   + p_down) / 4.0;

// integrate height
pressure += delta * pVel;

// restoring force toward zero (spring) + velocity damping + pressure damping
pVel     -= 0.005 * delta * pressure;
pVel     *= 1.0 - 0.002 * delta;
pressure *= 0.999;

// pointer injection: a small circular pressure bump under the cursor
vec2 mouseUV = mouse / resolution;
if (mouse.x > 0.0) {
    float dist = distance(uv, mouseUV);
    if (dist <= 0.02) {
        pressure += 2.0 * (1.0 - dist / 0.02);   // radius 0.02, peak +2.0 at the exact pointer
    }
}

// pack: R=pressure, G=velocity, B=x-gradient, A=y-gradient
gl_FragColor = vec4(pressure, pVel, (p_right - p_left) / 2.0, (p_up - p_down) / 2.0);

Notes on the constants (they define the feel): delta = 1.4 timestep; wave speed from the averaged 5-point Laplacian; -0.005*delta*pressure pulls the surface back to flat; *= 1.0 - 0.002*delta bleeds off velocity; *= 0.999 slowly damps height so ripples fade to stillness; injection radius 0.02 (in UV space) with linear falloff to peak +2.0 — a small, sharp, intense poke that spreads outward as a ring.

Render fragment shader (refraction + specular — reproduce exactly)

Uniforms: sampler2D textureA (water state), sampler2D textureB (text texture); varying vec2 vUv.

vec4 data = texture2D(textureA, vUv);

// refraction: offset the text-sample UV by the water's gradient (B,A channels)
vec2 distortion = 0.3 * data.zw;
vec4 color = texture2D(textureB, vUv + distortion);

// build a surface normal from the gradient and light it for a specular glint
vec3 normal   = normalize(vec3(-data.z * 2.0, 0.5, -data.w * 2.0));
vec3 lightDir = normalize(vec3(-3.0, 10.0, 3.0));
float specular = pow(max(0.0, dot(normal, lightDir)), 60.0) * 1.5;

gl_FragColor = color + vec4(specular);   // refracted text + additive white highlight

So the visible ripples come from two things at once: the **0.3 * gradient UV displacement that warps the wordmark, and the tight pow(...,60.0) * 1.5 specular** that paints bright white crests along the wave slopes.

Resize (window resize)

Recompute newWidth = innerWidth * devicePixelRatio, newHeight = innerHeight * devicePixelRatio, then:

  • renderer.setSize(innerWidth, innerHeight);
  • rtA.setSize(newWidth, newHeight); rtB.setSize(newWidth, newHeight);
  • simMaterial.uniforms.resolution.value.set(newWidth, newHeight);
  • Redraw the text canvas at the new size: resize the canvas, re-fill #fb7427, recompute fontSize = round(250 * devicePixelRatio), reset the cream bold …px font + center alignment, fillText("softhorizon", newWidth/2, newHeight/2), then textTexture.needsUpdate = true;

Assets / images

None — there are zero image files. The only "texture" is the wordmark rendered into a 2D canvas at runtime (see *The text texture*). Do not add any <img> or external asset.

Behavior notes

  • Desktop, pointer-driven. No scroll, click, or keyboard interaction. On mouseleave the cursor parks at (0,0) so injection stops and the existing ripples simply damp out to a flat surface.
  • The simulation runs forever at rAF cadence; the surface starts flat (frame == 0 clears both buffers) and only comes alive under pointer motion.
  • Performance is heavy / not mobile-safe: two full-viewport FloatType render targets at the raw (uncapped) device pixel ratio, plus two render passes per frame. FloatType render targets require float-texture support (OES_texture_float / WebGL2). There is no reduced-motion branch in the original.
  • Keep the two hexes (#fb7427, #fef4b8) identical between CSS, the canvas fill/text, and rely on LinearSRGBColorSpace so the WebGL water and the HTML page read as one continuous orange field.

</content> </invoke>