← Backgrounds

Ocean Swell

Layer upon layer of waves roll across a section, from small, pale ones out on the horizon to big, deep ones up front, each with a line of white foam breaking along its sharp crests.

Make waves

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-ocean-swell to a section with a sky-coloured background.

Options

Add any of these attributes to the same element.

data-colorsTwo colours: the far water, then the near water. Default #5ccfe6,#075985.
data-layersHow many layers of waves. Default 5.
data-heightWave height multiplier. Default 1.
data-horizonWhere the sea starts, from 0 (top) to 1 (bottom). Default 0.4.
data-speedSpeed multiplier. Default 1.
data-foam="false"No foam on the crests.

The waves follow a real wave shape (a trochoid), so the crests are sharp and the troughs wide, and bigger waves travel faster. It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the sea holds still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Ocean Swell: layered waves roll across a section, from small, pale ones
    // on the horizon to big, deep ones up front, each with a line of white
    // foam breaking along its crests. Drawn on a transparent canvas behind
    // your content, and paused when off screen.
    // Usage: <section data-ocean-swell style="background:linear-gradient(#7dd3fc,#e0f2fe)">…</section>
    // Optional: data-colors (two colours: the far water, then the near water,
    // default "#5ccfe6,#075985"), data-layers (number of waves, default 5),
    // data-height (wave height multiplier, default 1), data-speed (default 1),
    // data-horizon (where the sea starts, 0 = top, 1 = bottom, default 0.4),
    // data-foam="false" (no foam).
    var DEFAULTS = { colors: '#5ccfe6,#075985', layers: 5, height: 1, speed: 1, horizon: 0.4 };

    var css =
      '[data-ocean-swell]{position:relative;isolation:isolate;overflow:hidden}' +
      '.osw-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.trim();
      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);
    }
    function mix(a, b, f) { return [0, 1, 2].map(function (i) { return Math.round(a[i] + (b[i] - a[i]) * f); }); }
    function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }

    function build(el) {
      var d = el.dataset;
      var cols = (d.colors || DEFAULTS.colors).split(',');
      var far = rgb(cols[0]), near = rgb(cols[1] || cols[0]);
      var count = Math.max(1, +d.layers || DEFAULTS.layers);
      var height = d.height !== undefined ? +d.height : DEFAULTS.height;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var horizon = d.horizon !== undefined ? +d.horizon : DEFAULTS.horizon;
      var foamy = d.foam !== 'false';
      var canvas = document.createElement('canvas');
      canvas.className = 'osw-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, t = 0, layers = [];
      var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

      // Each layer is one wave train: further back means higher up the
      // section, smaller, paler and slower.
      var seeds = [];
      for (var i = 0; i < count; i++) seeds.push({ ph: R(0, 6.28, 2), ph2: R(0, 6.28, 2), dir: i % 2 ? 1 : 0.85 });

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        layers = seeds.map(function (s, i) {
          var f = count === 1 ? 1 : i / (count - 1);
          var lambda = Math.max(70, Math.min(w * 0.6, h * (0.35 + 0.9 * f)));
          var amp = h * (0.012 + 0.04 * f) * height;
          // Keep the crests sharp but never folding over.
          amp = Math.min(amp, lambda / (Math.PI * 2) / 0.95);
          return {
            f: f,
            base: h * (horizon + (1 - horizon) * (0.06 + 0.78 * Math.pow(f, 0.9))),
            amp: amp,
            k: (Math.PI * 2) / lambda,
            // Bigger waves travel faster, as they do at sea.
            omega: Math.sqrt((Math.PI * 2) / lambda) * 5 * s.dir,
            ph: s.ph, ph2: s.ph2,
            col: mix(far, near, f)
          };
        });
        draw();
      }

      // A trochoid: points on the surface circle as the wave passes, which
      // bunches them up at the crests (sharp) and spreads them in the troughs.
      function surface(L) {
        var pts = [], step = 5, q = 0.85;
        for (var p = -L.amp * 2 - step; p <= w + L.amp * 2 + step; p += step) {
          var th = L.k * p - L.omega * t + L.ph;
          var th2 = L.k * 1.7 * p + L.omega * 1.3 * t + L.ph2;
          var x = p - q * L.amp * Math.sin(th);
          var y = L.base - L.amp * Math.cos(th) - L.amp * 0.22 * Math.sin(th2);
          pts.push([x, y, Math.cos(th)]);
        }
        return pts;
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.lineJoin = 'round';
        for (var i = 0; i < layers.length; i++) {
          var L = layers[i], pts = surface(L);
          var top = L.base - L.amp * 1.25;
          // Body: lit near the crest, deepening below.
          var g = ctx.createLinearGradient(0, top, 0, Math.min(h, L.base + L.amp * 2 + h * 0.18));
          g.addColorStop(0, rgba(mix(L.col, [255, 255, 255], 0.22), 1));
          g.addColorStop(0.35, rgba(L.col, 1));
          g.addColorStop(1, rgba(mix(L.col, [0, 10, 30], 0.35), 1));
          ctx.fillStyle = g;
          ctx.beginPath();
          ctx.moveTo(pts[0][0], h + 2);
          for (var k = 0; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]);
          ctx.lineTo(pts[pts.length - 1][0], h + 2);
          ctx.closePath();
          ctx.fill();
          if (!foamy) continue;
          // Foam where the water is highest: a bright line along each crest,
          // and a fainter lace just behind it.
          var lw = 1 + 2.2 * L.f;
          for (var j = 1; j < pts.length; j++) {
            var c = (pts[j][2] + pts[j - 1][2]) / 2;
            var a = Math.max(0, (c - 0.35) / 0.65);
            if (a < 0.03) continue;
            ctx.strokeStyle = 'rgba(255,255,255,' + (a * (0.55 + 0.35 * L.f)).toFixed(3) + ')';
            ctx.lineWidth = lw;
            ctx.beginPath();
            ctx.moveTo(pts[j - 1][0], pts[j - 1][1] + lw * 0.3);
            ctx.lineTo(pts[j][0], pts[j][1] + lw * 0.3);
            ctx.stroke();
            if (L.f > 0.3 && (j * 7 + i * 3) % 4 === 0) {
              ctx.fillStyle = 'rgba(255,255,255,' + (a * 0.35).toFixed(3) + ')';
              var off = lw * 2 + ((j * 13) % 5) * lw * 0.9;
              ctx.beginPath();
              ctx.arc(pts[j][0], pts[j][1] + off, lw * 0.55, 0, Math.PI * 2);
              ctx.fill();
            }
          }
        }
      }

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

      var last = 0;
      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;
          t += (dt / 1000) * speed;
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-ocean-swell]').forEach(function (el) {
        if (el._osw) return;
        el._osw = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-ocean-swell style="background: linear-gradient(#7dd3fc, #e0f2fe); color: #fff">
  <h1>Make waves</h1>
</section>