← Backgrounds

Water Ripples

The section becomes a water surface. Tap or click it and ripples ring out from that spot, spreading, bouncing off the edges and crossing each other before they settle; drag across it to trail a wake. Now and then a raindrop falls on its own, so it’s never quite still.

Tap the water

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-water-ripples to a section with a watery background colour.

Options

Add any of these attributes to the same element.

data-rainRaindrops per second. Default 1; 0 for taps and clicks only.
data-sizeDrop size, as a multiplier. Default 1.
data-interactive="false"Ignore taps and the pointer.

It’s a small wave simulation, run on a coarse grid and smoothed up to full size, so it stays light; it pauses when off screen. Clicks still reach your links and buttons. With reduced motion it shows a few still ripples.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Water Ripples: the section becomes a still water surface. Tap or click
    // it and ripples ring out from that spot, spreading, bouncing off the
    // edges and crossing each other before they settle; drag across it to
    // trail a wake. Now and then a raindrop falls by itself, so it is never
    // completely still. A small wave simulation drawn on a canvas behind your
    // content, and paused when off screen.
    // Usage: <section data-water-ripples style="background:#0e7490">…</section>
    // Optional: data-rain (raindrops per second, default 1; 0 for clicks only),
    // data-size (drop size, as a multiplier, default 1), data-interactive="false"
    // (ignore taps and the pointer).
    var DEFAULTS = { rain: 1, size: 1 };

    var css =
      '[data-water-ripples]{position:relative;isolation:isolate;overflow:hidden}' +
      '.wrp-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function build(el) {
      var d = el.dataset;
      var rain = d.rain !== undefined ? +d.rain : DEFAULTS.rain;
      var dropSize = d.size !== undefined ? +d.size : DEFAULTS.size;
      var interactive = d.interactive !== 'false' && !el.closest('[aria-hidden="true"]');
      var canvas = document.createElement('canvas');
      canvas.className = 'wrp-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      // The water is simulated on a coarse grid and smoothed up to full size.
      var small = document.createElement('canvas');
      var sctx = small.getContext('2d');
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, cell = 4, gw = 0, gh = 0, cur = null, prev = null, img = null;
      var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        cell = Math.max(3, Math.min(5, Math.round(Math.min(w, h) / 80)));
        gw = Math.ceil(w / cell) + 2;
        gh = Math.ceil(h / cell) + 2;
        cur = new Float32Array(gw * gh);
        prev = new Float32Array(gw * gh);
        small.width = gw;
        small.height = gh;
        img = sctx.createImageData(gw, gh);
        if (still) {
          drop(w * 0.3, h * 0.45, 1.4);
          drop(w * 0.72, h * 0.6, 1);
          for (var i = 0; i < 40; i++) step();
        }
        draw();
      }

      // Push the surface down in a small disc, like a drop landing.
      function drop(x, y, strength) {
        var cx = Math.round(x / cell) + 1, cy = Math.round(y / cell) + 1;
        var r = Math.max(2, Math.round((3 * dropSize * 4) / cell));
        for (var j = -r; j <= r; j++) {
          for (var i = -r; i <= r; i++) {
            var px = cx + i, py = cy + j, dd = i * i + j * j;
            if (px < 1 || py < 1 || px >= gw - 1 || py >= gh - 1 || dd > r * r) continue;
            cur[py * gw + px] -= strength * 6 * Math.cos((Math.sqrt(dd) / r) * Math.PI / 2);
          }
        }
      }

      // One tick of the classic two-buffer wave simulation.
      function step() {
        for (var y = 1; y < gh - 1; y++) {
          for (var x = 1, i = y * gw + 1; x < gw - 1; x++, i++) {
            // Diagonal neighbours count too, so the rings come out round.
            var v = (cur[i - 1] + cur[i + 1] + cur[i - gw] + cur[i + gw] +
              (cur[i - gw - 1] + cur[i - gw + 1] + cur[i + gw - 1] + cur[i + gw + 1]) * 0.5) / 3 - prev[i];
            prev[i] = v * 0.982;
          }
        }
        var tmp = prev; prev = cur; cur = tmp;
      }

      function draw() {
        // Light from the top left: slopes facing it shine, the others shade.
        var px = img.data;
        for (var y = 0; y < gh; y++) {
          for (var x = 0; x < gw; x++) {
            var i = y * gw + x, o = i * 4;
            var sx = x > 0 && x < gw - 1 ? cur[i - 1] - cur[i + 1] : 0;
            var sy = y > 0 && y < gh - 1 ? cur[i - gw] - cur[i + gw] : 0;
            var s = (sx * 0.6 + sy * 0.8) * 0.45;
            if (s > 0) { px[o] = 255; px[o + 1] = 255; px[o + 2] = 255; px[o + 3] = Math.min(230, s * 255); }
            else { px[o] = 2; px[o + 1] = 30; px[o + 2] = 48; px[o + 3] = Math.min(170, -s * 180); }
          }
        }
        sctx.putImageData(img, 0, 0);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.imageSmoothingEnabled = true;
        ctx.imageSmoothingQuality = 'high';
        ctx.drawImage(small, -cell, -cell, gw * cell, gh * cell);
      }

      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
      if (still) return;

      if (interactive) {
        var lastTrail = 0;
        var pos = function (e) { var b = el.getBoundingClientRect(); return [e.clientX - b.left, e.clientY - b.top]; };
        el.addEventListener('pointerdown', function (e) { var p = pos(e); drop(p[0], p[1], 1.6); });
        el.addEventListener('pointermove', function (e) {
          // A mouse trails a wake as it moves; a finger only while touching.
          if (e.pointerType !== 'mouse' && !e.buttons && e.pressure === 0) return;
          var now = performance.now();
          if (now - lastTrail < 45) return;
          lastTrail = now;
          var p = pos(e);
          drop(p[0], p[1], e.buttons ? 0.7 : 0.35);
        });
      }

      var last = 0, acc = 0, nextRain = 0.3;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) : 16;
          last = now;
          if (rain > 0) {
            nextRain -= dt / 1000;
            if (nextRain <= 0) {
              drop(R(0, w, 1), R(0, h, 1), R(70, 130) / 100);
              nextRain = R(40, 160) / 100 / rain;
            }
          }
          // Step at a steady 60 per second, whatever the frame rate.
          acc += dt;
          var n = 0;
          while (acc >= 16 && n < 3) { step(); acc -= 16; n++; }
          if (n === 3) acc = 0;
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-water-ripples]').forEach(function (el) {
        if (el._wrp) return;
        el._wrp = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-water-ripples style="background: #0e7490; color: #fff">
  <h1>Tap the water</h1>
</section>