← Backgrounds

Wave Lines

Layered sine lines undulate across a section, each a little out of step with the next, so the bundle twists and breathes like a ribbon in the wind. The colours blend from the first line to the last.

Go with the flow

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-wave-lines to a section, hero or card.

Options

Add any of these attributes to the same element.

data-colorsTwo or more colours, blended across the lines. Default violet, pink and sky blue.
data-linesHow many lines. Default 12.
data-amplitudeWave height, as a share of the section’s height. Default 0.16.
data-positionWhere the waves sit, from 0 (top) to 1 (bottom). Default 0.62.
data-speedSpeed multiplier. Default 1.
data-widthLine width in px. Default 1.5.

It’s drawn on a canvas behind your content and paused when off screen. With reduced motion the lines stay still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Wave Lines: layered sine lines undulate across a section, each a little
    // out of step with the next, so the bundle twists and breathes like a
    // ribbon. Colours blend from the first line to the last. Drawn on a canvas
    // behind your content, and paused when off screen.
    // Usage: <section data-wave-lines>…</section>
    // Optional: data-colors (two or more, blended across the lines, default
    // #a855f7,#ec4899,#38bdf8), data-lines (default 12), data-amplitude
    // (wave height as a share of the section's height, default 0.16),
    // data-position (where the waves sit, 0 top to 1 bottom, default 0.62),
    // data-speed (default 1), data-width (line width in px, default 1.5).
    var DEFAULTS = { colors: ['#a855f7', '#ec4899', '#38bdf8'], lines: 12, amplitude: 0.16, position: 0.62, speed: 1, width: 1.5 };

    var css =
      '[data-wave-lines]{position:relative;isolation:isolate;overflow:hidden}' +
      '.wl-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);

    var probe = document.createElement('canvas').getContext('2d');
    function rgb(c) {
      probe.fillStyle = '#000';
      probe.fillStyle = c;
      var v = probe.fillStyle;
      if (v[0] === '#') return [parseInt(v.slice(1, 3), 16), parseInt(v.slice(3, 5), 16), parseInt(v.slice(5, 7), 16)];
      return v.match(/[\d.]+/g).slice(0, 3).map(Number);
    }
    // The colour a share t (0–1) of the way through the list.
    function blend(cols, t) {
      var f = t * (cols.length - 1), i = Math.min(cols.length - 2, Math.floor(f)), k = f - i;
      if (cols.length < 2) return 'rgb(' + cols[0].join(',') + ')';
      return 'rgb(' + [0, 1, 2].map(function (c) { return Math.round(cols[i][c] + (cols[i + 1][c] - cols[i][c]) * k); }).join(',') + ')';
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'wl-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var cols = (d.colors ? d.colors.split(',') : DEFAULTS.colors).map(function (c) { return rgb(c.trim()); });
      var lines = Math.max(1, +d.lines || DEFAULTS.lines);
      var amp = d.amplitude !== undefined ? +d.amplitude : DEFAULTS.amplitude;
      var pos = d.position !== undefined ? +d.position : DEFAULTS.position;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var width = +d.width || DEFAULTS.width;
      var strokes = [];
      for (var i = 0; i < lines; i++) strokes.push(blend(cols, lines > 1 ? i / (lines - 1) : 0));
      var w = 0, h = 0, dpr = 1;
      var clock = { t: 0 };

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        draw();
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.lineWidth = width;
        ctx.lineJoin = 'round';
        var t = clock.t, mid = h * pos, a = h * amp;
        var step = Math.max(6, w / 90);
        for (var i = 0; i < lines; i++) {
          var k = lines > 1 ? i / (lines - 1) : 0.5;
          // Each line shifts phase and height a little: the bundle twists.
          var phase = k * 2.4, lift = (k - 0.5) * a * 0.9;
          ctx.strokeStyle = strokes[i];
          ctx.globalAlpha = 0.35 + 0.65 * Math.sin(Math.PI * (0.15 + k * 0.7));
          ctx.beginPath();
          for (var x = -step; x <= w + step; x += step) {
            var u = x / Math.max(w, 1);
            var y = mid + lift * Math.sin(t * 0.7 + u * 3) +
              a * Math.sin(u * 5.2 + t + phase) * 0.62 +
              a * Math.sin(u * 2.3 - t * 0.6 + phase * 0.6) * 0.38;
            if (x === -step) ctx.moveTo(x, y); else ctx.lineTo(x, y);
          }
          ctx.stroke();
        }
        ctx.globalAlpha = 1;
      }

      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

      // One slow, endless clock drives every line. A lap is a whole number of
      // turns for each wave in it, so it loops without a jump.
      var lap = Math.PI * 20;
      var anim = anime.animate(clock, {
        t: [0, lap],
        duration: (lap / 0.9 / Math.max(0.01, speed)) * 1000,
        ease: 'linear',
        loop: true,
        autoplay: false,
        onUpdate: draw
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) anim.play(); else anim.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-wave-lines]').forEach(function (el) {
        if (el._wl) return;
        el._wl = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-wave-lines data-colors="#6366f1,#ec4899">
  <h1>Go with the flow</h1>
</section>