GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Image Ripple Effect CSS: 9 Canvas Wave Patterns

Nine image ripple effect css patterns, one canvas each. Item 9's hero scene is the finished ripple. Before that, item 5 was a reflection pool swaying.

Auto-plays · click a tile to jump to its section · all nine in one zip

Nine Image Ripple Effect CSS Patterns — Canvas Wave Math That Pushes Pixels

None of these nine load a photograph from anywhere. Each one calls a seedRand()-driven drawPhotoBase() once to paint a procedural gradient-and-fleck image onto an offscreen canvas, then reads it back with getImageData(). Every frame after that, the pattern recomputes a height field — a spring-damped two-buffer grid a pointer splashes into, a handful of summed Math.sin() waves, or a pointer-anchored radial falloff — turns the neighboring height difference into a small x/y pixel offset, resamples the base image at that offset, and writes the result back with putImageData(). That's why the order below runs from a single splash decaying across a 160×100 grid (item 1) through sine fields and lens geometry (items 2 through 8) to item 9, which reuses items 1, 2, and 5's math in one hero scene instead of computing a fourth wave of its own.

01Pointer Ripple Splash

Every point the pointer touches sends out a ring of ripples that pushes the image's pixels outward, each tap adding an impulse to a spring-damped 160×100 height grid that stepWave() propagates one cell per frame. RIPPLE_SPLASH_DECAY_MS (400) sets damping as Math.pow(0.02, 16 / RIPPLE_SPLASH_DECAY_MS), so a smaller value lets one splash die out before the next tap arrives while a larger one leaves several ripples overlapping.

Pointer Splash Ripple
Open in a new tab

01 · Pointer Splash Ripple — Measured area 15.95% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RIPPLE_SPLASH_DECAY_MSstepWavevirtualPointerOrbit
  var CALC_W = 160, CALC_H = 100;
  var LOOP_MS = 2000;
  var RIPPLE_SPLASH_DECAY_MS = 400;
  var SPLASH_AMP = -520;
  var REFRACT_K = 0.9;

  var gridA = new Float32Array(CALC_W * CALC_H);
  var gridB = new Float32Array(CALC_W * CALC_H);
  var damping = Math.pow(0.02, 16 / RIPPLE_SPLASH_DECAY_MS);

  function cellIndex(x, y) { return y * CALC_W + x; }

  function splashAt(gx, gy) {
    gx = Math.round(gx); gy = Math.round(gy);
    for (var dy = -1; dy <= 1; dy += 1) {
      for (var dx = -1; dx <= 1; dx += 1) {
        var x = gx + dx, y = gy + dy;
        if (x > 0 && x < CALC_W - 1 && y > 0 && y < CALC_H - 1) {
          gridA[cellIndex(x, y)] += SPLASH_AMP * (dx === 0 && dy === 0 ? 1 : 0.45);
        }
      }
    }
  }

Measured: 15.9% of the frame repaints across the loop, intensity averages 56.8, and 23 of 23 sampled frames moved.

02Ever-Rippling Ambient Background

Without any user input, overlapping sine waves keep the image's surface gently rippling on a loop, since ambientHeight() sums RIPPLE_AMBIENT_WAVE_COUNT (2) separate Math.sin() waves at every pixel every frame instead of reacting to a pointer. Raising that count layers in more waves at different angles and speeds, which is exactly what the first variant table row below does by pushing item 1's decay time, and what the second row does by pushing this count to 6.

Ambient Auto-Ripple Background
Open in a new tab

02 · Ambient Auto-Ripple Background — Measured area 66.05% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ambientHeightRIPPLE_AMBIENT_WAVE_COUNTREFRACT_K
  function ambientHeight(x, y, t) {
    var h = 0;
    for (var i = 0; i < RIPPLE_AMBIENT_WAVE_COUNT; i += 1) {
      var ang = (i / RIPPLE_AMBIENT_WAVE_COUNT) * Math.PI + 0.5;
      var freq = 0.05 + i * 0.021;
      var speed = 0.0017 + i * 0.0009;
      var amp = 6.5 / (i + 1);
      var dir = (i % 2 === 0) ? 1 : -1;
      h += amp * Math.sin(x * Math.cos(ang) * freq + y * Math.sin(ang) * freq + t * speed * dir);
    }
    return h;
  }

Measured: 66.0% of the frame repaints across the loop, intensity averages 32.2, and 23 of 23 sampled frames moved.

03Hover Reveal Through a Wave Front

On hover, a ripple spreads outward and a second image underneath gradually shows through along the wave front, with WAVE_BAND (10) setting how wide the mixing band around that expanding radius is before one photo fully replaces the other. When the pointer leaves, virtualHover() keeps driving the same reveal on a loop so the effect never sits static while nobody's touching it.

Hover Ripple That Reveals a Second Image
Open in a new tab

03 · Hover Ripple That Reveals a Second Image — Measured area 66.526% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

WAVE_BANDWAVE_FRONT_SPEEDvirtualHover
  function renderFrame(t) {
    var maxR = Math.sqrt(CALC_W * CALC_W + CALC_H * CALC_H);
    var idx = 0;
    for (var y = 0; y < CALC_H; y += 1) {
      for (var x = 0; x < CALC_W; x += 1) {
        var dx0 = x - origin.x, dy0 = y - origin.y;
        var dist = Math.sqrt(dx0 * dx0 + dy0 * dy0);
        var band = Math.sin(dist * 0.35 - t * 0.01);
        var mix = 1 - Math.max(0, Math.min(1, (dist - (radius - WAVE_BAND)) / WAVE_BAND));
        var rippled = dist + band * (mix > 0.02 && mix < 0.98 ? 2.4 : 0);
        var a = sampleAt(bufA, x + band * 0.6, y);
        var b = sampleAt(bufB, x - band * 0.6, y);
        var m = mix;
        outBuf[idx] = a[0] + (b[0] - a[0]) * m;
        outBuf[idx + 1] = a[1] + (b[1] - a[1]) * m;
        outBuf[idx + 2] = a[2] + (b[2] - a[2]) * m;
        outBuf[idx + 3] = 255;

Measured: 66.5% of the frame repaints across the loop, intensity averages 44.4, and 23 of 23 sampled frames moved.

04Pointer-Tracking Refraction Lens

Only the circular area around the pointer bends its pixels as if light were refracting through it, since everything inside LENS_RADIUS (26) pixels resamples the base image at an offset scaled by LENS_REFRACT_K (1.7) times a bulge-and-ripple factor. Everything outside that radius samples straight from its own coordinates, which is what makes the lens edge visible instead of blending smoothly into the rest of the photo.

Refraction Lens That Follows the Pointer
Open in a new tab

04 · Refraction Lens That Follows the Pointer — Measured area 21.081% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LENS_RADIUSLENS_REFRACT_KvirtualLens
  function renderFrame(t) {
    var idx = 0;
    for (var y = 0; y < CALC_H; y += 1) {
      for (var x = 0; x < CALC_W; x += 1) {
        var dxp = x - lens.x, dyp = y - lens.y;
        var dist = Math.sqrt(dxp * dxp + dyp * dyp);
        var rgb;
        if (dist < LENS_RADIUS) {
          var norm = dist / LENS_RADIUS;
          var bulge = (1 - norm) * (1 - norm);
          var ripple = Math.sin(dist * 0.9 - t * 0.012) * 0.6 * (1 - norm);
          var factor = LENS_REFRACT_K * bulge + ripple;
          var ux = dist > 0.0001 ? dxp / dist : 0;
          var uy = dist > 0.0001 ? dyp / dist : 0;
          var sx = x - ux * factor * 4;
          var sy = y - uy * factor * 4;
          rgb = sampleBase(sx, sy);
          var shine = Math.max(-20, Math.min(50, (1 - norm) * 30 - ripple * 14));
          rgb = [
            Math.max(0, Math.min(255, rgb[0] + shine)),
            Math.max(0, Math.min(255, rgb[1] + shine)),
            Math.max(0, Math.min(255, rgb[2] + shine))
          ];

Measured: 21.1% of the frame repaints across the loop, intensity averages 22.5, and 23 of 23 sampled frames moved.

05Reflection Pool Sway

Below the horizon line, a flipped copy of the image sways along a wave shape as if it were a reflection floating on water, with reflectWave() returning a depth-shifted Math.sin() value that REFLECT_SWAY_AMP (6.5) scales into a horizontal sampling offset. The deeper a row sits below HORIZON, the more that offset fades toward the bottom, which is what keeps the reflection looking anchored to the surface instead of sliding as one flat sheet.

Reflection Pool Beneath the Image
Open in a new tab

05 · Reflection Pool Beneath the Image — Measured area 25.191% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

REFLECT_SWAY_AMPREFLECT_GLOW_AMPreflectWave
  function renderFrame(t) {
    var idx = 0;
    var span = CALC_H - HORIZON;
    for (var y = 0; y < CALC_H; y += 1) {
      for (var x = 0; x < CALC_W; x += 1) {
        var rgb;
        var alpha = 1;
        var glow = 0;
        if (y < HORIZON) {
          rgb = sampleBase(x, y);
        } else {
          var depth = y - HORIZON;
          var srcY = HORIZON - depth;
          var frac = depth / span;
          var wave = reflectWave(depth, t);
          var offset = wave * REFLECT_SWAY_AMP * frac;
          rgb = sampleBase(x + offset, srcY);

Measured: 25.2% of the frame repaints across the loop, intensity averages 23.8, and 23 of 23 sampled frames moved.

06Card Flip Behind a Passing Wave

As the card flips, a single wave sweeps across the surface and swaps the front image for the back one, with frontX advancing across the canvas over CARD_FLIP_MS (900) and a FRONT_BAND (9) pixel-wide strip blending the two photos wherever the wave front is currently passing. Columns the front has already crossed show the back image outright, and columns it hasn't reached yet still show the front, so nothing pops instantly except right at that moving seam.

Card Flip Swept by a Ripple Wave
Open in a new tab

06 · Card Flip Swept by a Ripple Wave — Measured area 63.749% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

AUTO_CYCLE_MSCARD_FLIP_MSFRONT_BAND
  function renderFrame(t) {
    var frontX = progress * (CALC_W + FRONT_BAND * 2) - FRONT_BAND;
    var idx = 0;
    for (var y = 0; y < CALC_H; y += 1) {
      var wobble = Math.sin(y * 0.45 + t * 0.01) * 3.2;
      var localFront = frontX + wobble;
      for (var x = 0; x < CALC_W; x += 1) {
        var rgb;
        var d = x - localFront;
        if (d < -FRONT_BAND) {
          rgb = sampleAt(bufBack, x, y);
        } else if (d > FRONT_BAND) {
          rgb = sampleAt(bufFront, x, y);
        } else {
          var m = (d + FRONT_BAND) / (FRONT_BAND * 2);
          var a = sampleAt(bufFront, x, y);
          var b = sampleAt(bufBack, x, y);
          rgb = [a[0] + (b[0] - a[0]) * m, a[1] + (b[1] - a[1]) * m, a[2] + (b[2] - a[2]) * m];
          var shine = (1 - Math.abs(m - 0.5) * 2) * 34;
          rgb = [rgb[0] + shine, rgb[1] + shine, rgb[2] + shine];

Measured: 63.7% of the frame repaints across the loop, intensity averages 59.5, and 23 of 23 sampled frames moved.

07Audio-Reactive Ripple (No Microphone)

As a simulated audio level rises, the ripple's amplitude and spreading speed both grow with it — there's no microphone anywhere in this file, only virtualAudioLevel() combining three Math.sin() terms into a 0–1 curve that tick() checks against a 0.25 threshold every BEAT_INTERVAL_MS (220). RIPPLE_AUDIO_GAIN (0.4 by default, adjustable from 10 to 160 on the range slider labeled "Response strength") multiplies that level before it reaches splashAt(), so raising the gain makes even a quiet simulated beat punch a wide ripple.

Ripple That Grows With Sound Level
Open in a new tab

07 · Ripple That Grows With Sound Level — Measured area 19.934% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RIPPLE_AUDIO_GAINBEAT_INTERVAL_MSvirtualAudioLevel
  function tick(ts) {
    if (lastTs === null) lastTs = ts;
    var dt = ts - lastTs;
    lastTs = ts;
    if (playing) {
      elapsed += dt;
      var beatIdx = Math.floor(elapsed / BEAT_INTERVAL_MS);
      if (beatIdx !== lastBeatIdx) {
        lastBeatIdx = beatIdx;
        var level = virtualAudioLevel(elapsed);
        if (level > 0.25) {
          var gx = CALC_W * (0.3 + 0.4 * ((Math.sin(elapsed * 0.0017) + 1) / 2));
          var gy = CALC_H * (0.3 + 0.4 * ((Math.cos(elapsed * 0.0013) + 1) / 2));
          splashAt(gx, gy, -level * RIPPLE_AUDIO_GAIN * gainMul * 10);
        }
      }
      stepWave();
      draw();
    }

Measured: 19.9% of the frame repaints across the loop, intensity averages 33.3, and 23 of 23 sampled frames moved.

08Ripple Trapped Inside Letterforms

Inside a region clipped to the shape of the letters, the image ripples and sloshes as if liquid, because liquidHeight() only ever gets sampled where the text mask's alpha channel is 24 or higher. That function sums RIPPLE_TEXT_WAVE_COUNT (2) sine-times-cosine terms into a height value, and everywhere outside the mask is painted flat instead of being computed at all, which is why this item's area share stays among the lowest of the nine.

Image That Sloshes Inside Text
Open in a new tab

08 · Image That Sloshes Inside Text — Measured area 17.21% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

liquidHeightRIPPLE_TEXT_FIT_RATIORIPPLE_TEXT_WAVE_COUNT
  function liquidHeight(x, y, t) {
    var h = 0;
    for (var i = 0; i < RIPPLE_TEXT_WAVE_COUNT; i += 1) {
      var freq = 0.09 + i * 0.05;
      var speed = 0.0064 + i * 0.0032;
      var amp = 8 / (i + 1);
      h += amp * Math.sin(x * freq + t * speed) * Math.cos(y * (freq * 0.7) - t * speed * 0.7);
    }
    return h;

Measured: 17.2% of the frame repaints across the loop, intensity averages 29.6, and 23 of 23 sampled frames moved.

09Hero Scene Combining Splash, Ambient Wave, and Reflection

Combines the techniques above into one rippling image scene that fills a full hero section: above the horizon, upperRGB() adds the pointer-splash grid's height difference and ambientHeight()'s gradient at 0.85 and 1.1 weights respectively, and below the horizon that same combined field gets reused with a Math.sin() sway to stand in for a reflection. In short, item 9 is items 1, 2, and 5's math folded into a single renderFrame() instead of a fourth calculation of its own.

Hero Ripple Scene Combining the Techniques Above
Open in a new tab

09 · Hero Ripple Scene Combining the Techniques Above — Measured area 61.751% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

HERO_SPLASH_DECAY_MSupperRGBambientHeight
  function upperRGB(x, y, t) {
    var i = y * CALC_W + x;
    var hl = x > 0 ? gridA[i - 1] : gridA[i];
    var hr = x < CALC_W - 1 ? gridA[i + 1] : gridA[i];
    var hu = y > 0 ? gridA[i - CALC_W] : gridA[i];
    var hd = y < HORIZON - 1 ? gridA[i + CALC_W] : gridA[i];
    var amb = ambientHeight(x, y, t);
    var ambX = ambientHeight(x + 1, y, t) - ambientHeight(x - 1, y, t);
    var ambY = ambientHeight(x, y + 1, t) - ambientHeight(x, y - 1, t);
    var dx = (hr - hl) * 0.85 + ambX * 1.1;
    var dy = (hd - hu) * 0.85 + ambY * 1.1;
    var rgb = sampleBase(x + dx, y + dy);
    var shine = Math.max(-30, Math.min(55, (hu - hd) * 0.5 + amb * 4));
    return [
      Math.max(0, Math.min(255, rgb[0] + shine)),
      Math.max(0, Math.min(255, rgb[1] + shine)),
      Math.max(0, Math.min(255, rgb[2] + shine))
    ];

Measured: 61.8% of the frame repaints across the loop, intensity averages 38.1, and 23 of 23 sampled frames moved.

Where These Nine Image Ripple Effects Actually Show Up

Three of the nine work fine as a set-and-forget backdrop: item 2's ambient waves and item 9's combined hero scene both suit a hero background or loading screen where nobody has to touch anything — unlike a looping image wave effect gif exported once and replayed forever, both keep recomputing their height field live, so panning the browser window never reveals a seam. Item 5's swaying reflection fits a brand logo intro or portfolio hero section where the sway itself is the point. The other six lean interactive: item 1's splash belongs on a product image hover or an interactive banner, item 3's reveal suits a comparison image card or a product color swap, item 4's lens reads well on a product detail image or a portfolio preview, item 6's wave-flip fits a card detail transition or an event banner swap, item 7's gain slider is closer to a small tool for a music album cover or podcast player than a plain backdrop, and item 8's letterform ripple fits a brand typography hero or event title where the text itself needs to look alive.

Nine Image Ripple Effect CSS Patterns, Measured Frame by Frame

Item Area % Intensity Frames moved
01 ripple-canvas-pointer-splash 15.9 56.8 23/23
02 ripple-canvas-auto-loop-scene 66.0 32.2 23/23
03 ripple-canvas-hover-reveal 66.5 44.4 23/23
04 ripple-canvas-refraction-lens 21.1 22.5 23/23
05 ripple-canvas-reflection-pool 25.2 23.8 23/23
06 ripple-canvas-card-flip-wave 63.7 59.5 23/23
07 ripple-canvas-audio-reactive 19.9 33.3 23/23
08 ripple-canvas-text-liquid 17.2 29.6 23/23
09 ripple-canvas-hero-scene 61.8 38.1 23/23

Item 1 sits at the lowest area share, 15.9%, because a single splash only ever touches the small ring of grid cells stepWave() has had time to propagate to, while item 3's hover reveal and item 2's ambient field both cross the 66% mark by touching nearly the whole 160×100 buffer every frame. Item 6 posts the highest intensity, 59.5, since a full-strength wave front swaps in a completely different photo across a 9-pixel band instead of nudging the same photo's own pixels the way every other item here does. All nine hit a clean 23 of 23 sampled frames, with no dropped-frame exception at all.

Where the Value Drives the Wave Shape — Decay Time, Wave Count, Audio Gain

Three constants carry most of the felt difference in motion. RIPPLE_SPLASH_DECAY_MS sets how fast stepWave() lets item 1's splash height die out (400 by default), and item 9 runs the same damping = Math.pow(0.02, 16 / decay) formula under its own HERO_SPLASH_DECAY_MS constant (420 by default) — different name, same math, so one tap still fades before the next lands. The variant table's first row below stretches item 1's value to 1400 and leaves several ripples overlapping at once. RIPPLE_AMBIENT_WAVE_COUNT sets how many Math.sin() terms ambientHeight() sums at every pixel every frame — items 2 and 9 default to 2, and the second variant row raises it to 6, layering in waves at different angles and speeds instead of one plain drift. RIPPLE_AUDIO_GAIN multiplies item 7's simulated virtualAudioLevel() reading before it reaches splashAt() — the default 0.4 keeps quiet beats subtle, and the third variant row's 1.6 makes even a small simulated level punch a ripple as wide as a real tap.

Where It Breaks — Reduced Motion, Low-End Devices, Large Image Performance

Every item here still walks the same 160×100 buffer (CALC_W/CALC_H) pixel by pixel inside its render function before draw() scales the result up onto the visible canvas — cheap at this size, but the same loop against a full 1920×1080 hero image would mean well over a hundred times more Math.sin(), Math.sqrt(), or getImageData() calls every frame, which is where a low-end phone starts dropping frames first on item 9, since upperRGB() calls ambientHeight() three separate times per pixel on top of reading the splash grid. e7jhekfs protecting every index.html/style.scss pair here sits in this paragraph on purpose — read this far and you already know which pattern to profile first before you unzip the rest. If you only need one still frame instead of the animation, every one of these nine is a plain <canvas> element, so right-click any frame and save it as an image ripple effect css png — no server-side export required.

Three Variants and Accessibility

Variant Value change Feel
Adjust the ripple-decay-time value 01 RIPPLE_SPLASH_DECAY_MS 400 → 1400 Lowering the value makes the ripple fade almost instantly for a quick tap feel, and raising it keeps the ripple alive long enough that several waves overlap.
Adjust the ambient-wave-count value 02 RIPPLE_AMBIENT_WAVE_COUNT 2 → 6 Lowering the value leaves a single wave drifting plainly, and raising it layers several waves together for a far more complex, natural-looking motion.
Adjust the audio-reactive-gain value 07 RIPPLE_AUDIO_GAIN 0.4 → 1.6 Lowering the value keeps the ripple nearly unchanged even as the sound gets louder, and raising it makes even a quiet sound send the ripple sloshing wide.

prefers-reduced-motion: reduce stops the animation loop on every item and draws a single static frame instead — items 2, 5, and 8 deliberately offset that frozen frame's internal clock to 900ms, 700ms, and 600ms respectively instead of 0, so a reader with motion sensitivity still sees each pattern's typical wave shape rather than a blank starting frame. Items 1, 4, 6, and 9 also answer the keyboard directly: each wraps its canvas in an element with tabIndex set to 0 and a keydown listener — 1 and 9 fire the same splash a pointer tap would on Enter or Space, 4 nudges the lens with the arrow keys, and 6 toggles the flip on Enter or Space just like a click. Item 7's gain control is a native <input type="range"> labeled "Response strength," so arrow-key support comes from the browser for free. Item 3 skips keydown entirely but still reaches keyboard users a different way: its wrapper's focus and blur listeners open and close the same reveal that pointerenter/pointerleave do, so tabbing to the canvas starts the reveal from the center and tabbing away ends it, while the automatic virtualHover() loop keeps it moving whenever nobody's touching it. getImageData(), ImageData, requestAnimationFrame(), and the prefers-reduced-motion media query are all documented on MDN.

How Is a CSS Water Wave Effect Different From This Image Ripple Effect?

A css water wave effect search mostly turns up a flat box animated with border-radius, box-shadow, or an SVG <feDisplacementMap> filter — the wave lives in the container's edge or a filter graph, and the photo underneath, if there's one at all, never actually moves. Every pattern in this post instead calls getImageData() on the same 160×100 buffer, computes a height field, and resamples the base pixels at a small offset before putImageData() writes the result back, so the water ripple effect css codepen comparison that actually matters is whether a demo touches pixel data or just animates a shape's outline.

For nearby canvas patterns built the same value-driven way, HTML5 Canvas Animation: 9 Warp & Droplet Effects covers pixel-level distortion with a different set of forces, 9 CSS Image Filter Effects (Copy-Paste) shows what the same photo looks like run through CSS filter functions instead of a canvas loop, and Image Zoom Viewer UI: 9 Copy-Paste Patterns is the closest relative for pointer-anchored interaction on a single image.

Questions Worth Answering Before You Try One of These Nine

Does a real photo or a canvas library sit behind any of these nine?

No — every one calls its own drawPhotoBase(), which paints a seeded gradient-and-fleck image onto an offscreen canvas using nothing but rand(). Nothing here loads a photo file or pulls in a canvas library, so getImageData() and putImageData() are doing all of the work you can actually copy out of the zip.

Which of the nine is the most demanding on a low-end phone?

Item 9, since upperRGB() calls ambientHeight() three times per pixel on top of reading the splash grid that every tap feeds. Item 8's liquidHeight() is the next closest, sampling a wave function twice per pixel, but only inside the clipped letterform mask instead of the full 160×100 buffer, which is why its measured area stays far lower.

Is a css water drop effect tutorial built the same way as these nine ripples?

Only if that tutorial also reads and rewrites pixel data — most css water drop effect writeups animate a border-radius blob or an SVG filter over a fixed image. Item 4's lens and item 1's splash both call getImageData() so the water actually bends the photo's own pixels instead of a shape drawn on top of it.

Enter the archive password

The password is inside this article. You will find it as you read.