← SVG & icons

Waving Flag

A flag ripples in the wind. Waves roll along it from the pole and grow towards the free end, with light catching the folds that face you and shade on the ones turning away. Give it any image or inline SVG.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap the flag, an <img> or an inline <svg>, in an element with data-waving-flag.
  3. The script draws it onto a canvas in thin strips and moves each one; the original stays in place, invisible, for its size and alt text.

Options

Add any of these attributes to the same element.

data-amplitudeHeight of the waves, as a share of the flag’s height. Default 0.08.
data-wavesNumber of waves along the flag. Default 1.4.
data-speedHow fast the wind blows. Default 1.
data-shadeStrength of the light and shade on the folds, from 0 to 1. Default 0.35.
data-pinThe side held by the pole: left, right or none for a banner that ripples all over. Default left.

The waves spill a little above and below the flag, so leave some room. An inline SVG should set its colours with attributes rather than page CSS. It pauses while off screen, and with reduced motion it holds one still ripple.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Waving Flag: a flag ripples in the wind. Waves roll along it from the
    // pole, growing towards the free end, with light and shade on the folds.
    // Give it an <img> or an inline <svg> of the flag; the script draws it onto
    // a canvas in thin strips and moves each one.
    // Usage: <div data-waving-flag><img src="/flag.png" alt="Our flag"></div>
    // Optional: data-amplitude (how high the waves are, as a share of the
    // flag's height, default 0.08), data-waves (waves along the flag, default
    // 1.4), data-speed (default 1), data-shade (strength of the light and
    // shade, 0 to 1, default 0.35), data-pin (left, right or none: the side
    // held by the pole, default left).
    var DEFAULTS = { amplitude: 0.08, waves: 1.4, speed: 1, shade: 0.35 };

    var css = '.wvf-host{position:relative}' +
      '.wvf-src{opacity:0}' +
      '.wvf-canvas{position:absolute;left:0;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 num(v, f) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : f; }

    // Turn the source into something drawImage can use: an <img>, or an inline
    // <svg> serialised into one.
    function loadSource(src, w, h, done) {
      if (src.tagName.toLowerCase() === 'img') {
        if (src.complete && src.naturalWidth) done(src);
        else src.addEventListener('load', function () { done(src); }, { once: true });
        return;
      }
      var copy = src.cloneNode(true);
      copy.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
      copy.setAttribute('width', w);
      copy.setAttribute('height', h);
      copy.removeAttribute('class');
      copy.removeAttribute('style');
      var img = new Image();
      img.onload = function () { done(img); };
      img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(new XMLSerializer().serializeToString(copy));
    }

    function setup(el) {
      var src = el.querySelector('img, svg');
      if (!src) return;
      var d = el.dataset;
      el.classList.add('wvf-host');
      var canvas = document.createElement('canvas');
      canvas.className = 'wvf-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      var flat = document.createElement('canvas'), fctx = flat.getContext('2d');
      var pin = d.pin === 'right' || d.pin === 'none' ? d.pin : 'left';
      var img = null, w = 0, h = 0, pad = 0, dpr = 1, ox = 0, oy = 0;
      var visible = false, raf = 0, last = 0, clock = 0;

      function size() {
        var r = src.getBoundingClientRect(), er = el.getBoundingClientRect();
        w = r.width; h = r.height;
        if (!w || !h) return false;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        pad = Math.ceil(h * num(d.amplitude, DEFAULTS.amplitude) * 1.3) + 2;
        // Line the canvas up with the source, with room above and below for the waves.
        ox = r.left - er.left - el.clientLeft; oy = r.top - er.top - el.clientTop;
        canvas.style.left = ox + 'px';
        canvas.style.top = (oy - pad) + 'px';
        canvas.style.width = w + 'px';
        canvas.style.height = (h + 2 * pad) + 'px';
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round((h + 2 * pad) * dpr);
        flat.width = canvas.width;
        flat.height = Math.round(h * dpr);
        if (img) fctx.drawImage(img, 0, 0, flat.width, flat.height);
        return true;
      }

      function draw(t) {
        if (!img) return;
        var A = h * num(d.amplitude, DEFAULTS.amplitude);
        var k = Math.PI * 2 * num(d.waves, DEFAULTS.waves) / w;
        var shade = num(d.shade, DEFAULTS.shade);
        var phase = t * 0.0042 * num(d.speed, DEFAULTS.speed);
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        var step = 1, fw = flat.width / w;
        for (var x = 0; x < w; x += step) {
          // How free this strip is to move: nothing at the pole, everything at the far end.
          var u = pin === 'left' ? x / w : pin === 'right' ? 1 - x / w : 1;
          var free = pin === 'none' ? 1 : Math.pow(u, 0.8);
          var wave = Math.sin(k * x - phase) + 0.3 * Math.sin(k * 2.1 * x - phase * 1.7 + 1.3);
          var y = pad + A * free * wave * 0.8;
          // The cloth bunches up a little in the folds, so the strip gets a touch shorter.
          var slope = Math.cos(k * x - phase);
          var sh = h * (1 - 0.04 * free * Math.abs(slope));
          var sw = Math.min(step + 0.6, w - x);
          ctx.drawImage(flat, x * fw, 0, sw * fw, flat.height, x, y + (h - sh) / 2, sw, sh);
          // Light on the folds facing us, shade on the ones turning away.
          var lit = -slope * free * shade;
          if (Math.abs(lit) > 0.01) {
            ctx.globalCompositeOperation = 'source-atop';
            ctx.fillStyle = lit > 0 ? 'rgba(255,255,255,' + (lit * 0.55).toFixed(3) + ')' : 'rgba(0,0,0,' + (-lit * 0.7).toFixed(3) + ')';
            ctx.fillRect(x, y + (h - sh) / 2 - 1, step, sh + 2);
            ctx.globalCompositeOperation = 'source-over';
          }
        }
      }

      function frame(now) {
        raf = 0;
        if (!visible) return;
        clock += Math.min(50, now - (last || now));
        last = now;
        draw(clock);
        raf = requestAnimationFrame(frame);
      }
      function wake() { last = 0; if (visible && !raf && img && !reduced()) raf = requestAnimationFrame(frame); }

      size();
      loadSource(src, Math.round(w * 2) || 600, Math.round(h * 2) || 400, function (i) {
        img = i;
        src.classList.add('wvf-src');
        size();
        // With reduced motion, show one still frame of a gentle wave.
        draw(reduced() ? 400 : clock);
        wake();
      });
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        wake();
      }).observe(el);
      if (window.ResizeObserver) new ResizeObserver(function () { if (size()) draw(clock || 400); }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-waving-flag]').forEach(function (el) {
        if (el._wvf) return;
        el._wvf = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-waving-flag style="width: 240px">
  <img src="/flag.png" alt="Our flag" width="240">
</div>