← Text effects

Water Reflection Text

An upside-down copy of the text hangs under its baseline like a reflection on a still lake. It fades with depth, and gentle ripples keep pulling it sideways and shimmering across it.

Still waters

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-water-reflection-text to a single-line heading, with some space below it for the reflection.

Options

Add any of these attributes to the same element.

data-amplitudeHow far the ripples push the reflection sideways, in em. Default 0.05.
data-speedHow fast the ripples run. Default 1.
data-opacityHow strong the reflection is at the waterline, from 0 to 1. Default 0.55.
data-lengthHow far down the reflection reaches, in em. Default 0.9.
data-tintA colour washed over the reflection, like the colour of the water. Default none.
data-colorColour of the reflection. Default the text’s colour.

The reflection is drawn on a canvas in the element’s own font and hangs below the element without taking up space, so give it a bottom margin. With reduced motion turned on the water stays still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Water Reflection Text: a mirrored copy of the text hangs under its
    // baseline like a reflection on a lake, fading with depth while ripples
    // wobble it sideways and shimmer across it.
    // Usage: <h1 data-water-reflection-text>Still waters</h1>
    // Optional: data-amplitude (how far the ripples push the reflection
    // sideways, in em; default 0.05), data-speed (ripple speed; default 1),
    // data-opacity (how strong the top of the reflection is, 0 to 1; default
    // 0.55), data-length (how far down the reflection reaches, in em; default
    // 0.9), data-tint (colour washed over the reflection, e.g. a water blue;
    // default none), data-color (colour of the reflection; default the text
    // colour).
    // The reflection hangs below the element, so leave some space under it.
    var DEFAULTS = { amplitude: 0.05, speed: 1, opacity: 0.55, length: 0.9 };

    var css =
      '.wrt-line{position:relative;display:inline-block;white-space:nowrap}' +
      '.wrt-base{display:inline-block;width:0;height:0;vertical-align:baseline}' +
      '.wrt-canvas{position:absolute;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var d = el.dataset;
      var amp = d.amplitude !== undefined ? +d.amplitude : DEFAULTS.amplitude;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var opacity = d.opacity !== undefined ? +d.opacity : DEFAULTS.opacity;
      var length = +d.length || DEFAULTS.length;

      var text = el.textContent.replace(/\s+/g, ' ').trim();
      var line = document.createElement('span');
      line.className = 'wrt-line';
      while (el.firstChild) line.appendChild(el.firstChild);
      // A zero-size marker sitting on the baseline tells us where the water is.
      var base = document.createElement('span');
      base.className = 'wrt-base';
      line.appendChild(base);
      var canvas = document.createElement('canvas');
      canvas.className = 'wrt-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      line.appendChild(canvas);
      el.appendChild(line);

      var ctx = canvas.getContext('2d');
      var src = document.createElement('canvas');
      var sctx = src.getContext('2d');
      var W = 0, H = 0, pad = 0, fs = 16, dpr = 1, t = 0;

      // Draw the text once, right way up, with its baseline on the bottom edge
      // of the offscreen canvas; frames then copy it out row by row, flipped.
      function build() {
        var cs = getComputedStyle(el);
        fs = parseFloat(cs.fontSize);
        dpr = Math.min(2, window.devicePixelRatio || 1);
        pad = Math.ceil(fs * (amp * 2 + 0.1));
        W = Math.ceil(line.offsetWidth + pad * 2);
        H = Math.ceil(fs * length);
        canvas.width = src.width = W * dpr;
        canvas.height = src.height = H * dpr;
        canvas.style.width = W + 'px';
        canvas.style.height = H + 'px';
        canvas.style.left = -pad + 'px';
        canvas.style.top = (base.offsetTop + fs * 0.03) + 'px';
        sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        sctx.clearRect(0, 0, W, H);
        sctx.font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
        if ('letterSpacing' in sctx) sctx.letterSpacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing;
        var color = d.color || cs.color;
        if (/rgba?\(.*,\s*0\)$/.test(color) || color === 'transparent') color = '#888';
        sctx.fillStyle = color;
        sctx.textBaseline = 'alphabetic';
        sctx.fillText(text, pad, H);
        draw();
      }

      function draw() {
        if (!W) return;
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.globalCompositeOperation = 'source-over';
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        var a = amp * fs;
        var step = Math.max(1, Math.round(fs / 48));
        for (var y = 0; y < H; y += step) {
          var depth = y / H;
          // Ripples grow as they move away from the waterline.
          var dx = a * (0.25 + depth) * (Math.sin(y * 0.32 - t * 3.1) * 0.7 + Math.sin(y * 0.11 + t * 1.7) * 0.3);
          var dy = Math.sin(y * 0.2 - t * 2.3) * depth * step * 1.5;
          var sy = Math.max(0, Math.min(H - step, H - y - step + dy));
          ctx.globalAlpha = 0.86 + 0.14 * Math.sin(y * 0.55 - t * 4);
          ctx.drawImage(src, 0, sy * dpr, W * dpr, step * dpr, dx * dpr, y * dpr, W * dpr, step * dpr);
        }
        ctx.globalAlpha = 1;
        // Fade the reflection out with depth.
        ctx.globalCompositeOperation = 'destination-in';
        var g = ctx.createLinearGradient(0, 0, 0, H * dpr);
        g.addColorStop(0, 'rgba(0,0,0,' + opacity + ')');
        g.addColorStop(1, 'rgba(0,0,0,0)');
        ctx.fillStyle = g;
        ctx.fillRect(0, 0, W * dpr, H * dpr);
        if (d.tint) {
          ctx.globalCompositeOperation = 'source-atop';
          ctx.globalAlpha = 0.45;
          ctx.fillStyle = d.tint;
          ctx.fillRect(0, 0, W * dpr, H * dpr);
          ctx.globalAlpha = 1;
        }
      }

      build();
      if (document.fonts && document.fonts.ready) document.fonts.ready.then(build);
      var lastW = 0;
      new ResizeObserver(function () {
        if (line.offsetWidth === lastW) return;
        lastW = line.offsetWidth;
        build();
      }).observe(line);

      if (reduced()) return;

      var prev = 0;
      var ticker = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          t += (prev ? Math.min(50, now - prev) : 16) / 1000 * speed;
          prev = now;
          draw();
        }
      });

      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { prev = 0; ticker.play(); }
        else ticker.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-water-reflection-text]').forEach(function (el) {
        if (el._wrt) return;
        el._wrt = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1 data-water-reflection-text style="margin-bottom: 0.9em">Still waters</h1>

<!-- a bluer lake with bigger ripples: -->
<h1 data-water-reflection-text data-tint="#38bdf8" data-amplitude="0.09">Still waters</h1>