Copied!
Animated SVG backgrounds

Layered wave generator

Stacked SVG waves rolling sideways along the bottom of a panel, redrawn every frame from real wave maths, with sliders for every part of the shape.

← Library

Element · Vanilla JS + SVGUpdated November 6, 2026

SVG backgroundsElementVanilla JS + SVG

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Layered wave generator" for my site using the Enrichr Library element at https://enrichr.dev/elements/layered-wave-generator.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/layered-wave-generator.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- height: 40
- width: 300
- speed: 1
- warp: 0
- turbulence: 0
- layers: 3
- depth: 50
- roundness: 70
- area: 220
- color: #fbf8f1
- background: #005236

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/layered-wave-generator.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-lwg">
  <svg class="ef-lwg__svg" viewBox="0 0 1000 300" preserveAspectRatio="none" role="img" aria-label="Layered waves rolling sideways along the bottom of the panel"></svg>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-lwg { --ef-lwg-area: 220px; position: relative; width: 100%; height: 100%; min-height: 20rem; overflow: hidden; border-radius: 1rem; background: #005236; }
.ef-lwg .ef-lwg__svg { position: absolute; left: 0; bottom: 0; display: block; width: 100%; height: min(var(--ef-lwg-area), 100%); }

JavaScript

// Enrichr Library · https://enrichr.dev
// Layered wave generator: stacked SVG waves that roll sideways, redrawn every frame from real wave maths, no keyframes.
// Each layer samples 49 points across a 1000 by 300 viewBox around a line 40% down: a sine of the chosen wavelength,
// plus an optional second harmonic (warp, which leans the crests one way) and two faster, shorter sines (turbulence,
// for chop). Back layers are 40% flatter, start 0.6 radians later and fade by the depth setting. The points are joined
// with quadratic curves through their midpoints (roundness blends from straight corners to fully round) and closed along
// the bottom. The phase advances one full cycle every 8 seconds at speed 1, as the original GSAP ticker did.
// Reduced motion draws one still frame. The loop pauses off screen and in hidden tabs, and stops at speed 0.
// mount(root, { height (wave height), width (wavelength, viewBox units), speed, warp (-1 to 1), turbulence (0-100),
//               layers (1-6), depth (0-100), roundness (0-100), color (waves), background, area (wave strip height, px) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-lwg");
  var svg = root.querySelector(".ef-lwg__svg");
  var NS = "http://www.w3.org/2000/svg", VBW = 1000, VBH = 300, STEPS = 48, TAU = 6.283;
  var opts = {}, paths = [], phase = 0, raf = 0, last = 0, visible = true, io = null, destroyed = false;

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function r2(v) { return Math.round(v * 100) / 100; }

  function buildLayers() {
    svg.textContent = "";
    paths = [];
    var n = opts.layers;
    for (var i = 0; i < n; i++) {
      var p = document.createElementNS(NS, "path");
      svg.appendChild(p); // layer 0 ("front", fully opaque) is painted first, as in the original
      paths.push({ el: p, depthT: 0 });
    }
    styleLayers();
  }
  function styleLayers() {
    var n = paths.length;
    paths.forEach(function (layer, i) {
      layer.depthT = n === 1 ? 1 : i / (n - 1); // 0 = front, 1 = back
      var opacity = 1 - layer.depthT * (opts.depth / 100) * 0.85;
      layer.el.setAttribute("fill", opts.color);
      layer.el.setAttribute("opacity", Math.max(0.08, opacity).toFixed(2));
    });
  }

  function computeY(x, ph, depthT) {
    var wl = Math.max(20, opts.width);
    var amp = opts.height * (1 - depthT * 0.4); // back layers slightly flatter
    var warp = opts.warp, turb = opts.turbulence / 100;
    var base = Math.sin((x / wl) * Math.PI * 2 + ph);
    var harmonic = Math.sin((x / wl) * Math.PI * 4 + ph * 1.7) * Math.abs(warp) * (warp >= 0 ? 1 : -1) * 0.5;
    var chop = turb > 0 ? (Math.sin((x / (wl * 0.31)) * Math.PI * 2 + ph * 3.1) * 0.35 + Math.sin((x / (wl * 0.13)) * Math.PI * 2 - ph * 4.7) * 0.25) * turb : 0;
    return amp * (base + harmonic) + amp * chop * 0.6;
  }
  // The original joins midpoints with Q curves whose control is each sample (full rounding). Here roundness t shrinks
  // that rounding toward each corner: t = 1 is the original curve, t = 0 is straight lines through the samples.
  function toPath(pts, t) {
    var d = "M" + r2(pts[0][0]) + "," + r2(pts[0][1]), i;
    if (pts.length < 3 || t <= 0) {
      for (i = 1; i < pts.length; i++) d += " L" + r2(pts[i][0]) + "," + r2(pts[i][1]);
      return d;
    }
    for (i = 1; i < pts.length - 1; i++) {
      var p = pts[i], a = pts[i - 1], b = pts[i + 1];
      var ax = p[0] + ((a[0] + p[0]) / 2 - p[0]) * t, ay = p[1] + ((a[1] + p[1]) / 2 - p[1]) * t;
      var bx = p[0] + ((b[0] + p[0]) / 2 - p[0]) * t, by = p[1] + ((b[1] + p[1]) / 2 - p[1]) * t;
      d += " L" + r2(ax) + "," + r2(ay) + " Q" + r2(p[0]) + "," + r2(p[1]) + " " + r2(bx) + "," + r2(by);
    }
    var lastPt = pts[pts.length - 1];
    return d + " L" + r2(lastPt[0]) + "," + r2(lastPt[1]);
  }
  function draw() {
    var midY = VBH * 0.4, t = Math.max(0, Math.min(1, opts.roundness / 100));
    paths.forEach(function (layer, idx) {
      var lp = phase + idx * 0.6, pts = [];
      for (var s = 0; s <= STEPS; s++) { var x = (s / STEPS) * VBW; pts.push([x, midY + computeY(x, lp, layer.depthT)]); }
      layer.el.setAttribute("d", toPath(pts, t) + " L" + VBW + "," + VBH + " L0," + VBH + " Z");
    });
  }

  function loop(now) {
    raf = 0;
    if (destroyed || reduce || opts.speed <= 0) return;
    raf = requestAnimationFrame(loop);
    var dt = (now - last) / 1000; last = now;
    if (dt > 0.5 || dt < 0) dt = 0.033;
    if (!visible || document.hidden) return;
    phase += dt * TAU * opts.speed / 8; // one cycle (6.283) every 8 / speed seconds, linear
    draw();
  }
  function kick() { if (!raf && !destroyed && !reduce && opts.speed > 0) { last = performance.now(); raf = requestAnimationFrame(loop); } }

  function update(partial) {
    var prevLayers = opts.layers;
    opts = Object.assign({ height: 40, width: 300, speed: 1, warp: 0, turbulence: 0, layers: 3, depth: 50, roundness: 70, color: "#fbf8f1", background: "#005236", area: 220 }, opts, partial || {});
    opts.height = Math.max(0, num(opts.height, 40)); opts.width = num(opts.width, 300); opts.speed = Math.max(0, num(opts.speed, 1));
    opts.warp = Math.max(-1, Math.min(1, num(opts.warp, 0))); opts.turbulence = Math.max(0, num(opts.turbulence, 0));
    opts.layers = Math.max(1, Math.min(6, Math.round(num(opts.layers, 3)))); opts.depth = num(opts.depth, 50); opts.roundness = num(opts.roundness, 70);
    opts.area = Math.max(40, num(opts.area, 220));
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-lwg-area", opts.area + "px");
    if (opts.layers !== prevLayers || !paths.length) buildLayers(); else styleLayers();
    draw();
    kick();
  }

  update(o);
  if (!reduce && "IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
  return {
    update: update,
    destroy: function () { destroyed = true; cancelAnimationFrame(raf); raf = 0; if (io) io.disconnect(); }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"height":40,"width":300,"speed":1,"warp":0,"turbulence":0,"layers":3,"depth":50,"roundness":70,"area":220,"color":"#fbf8f1","background":"#005236"});

How it works

The waves live in one SVG with a 1000 by 300 viewBox stretched to the panel width, anchored to the bottom. Each frame, every layer samples 49 points across the width around a line 40% of the way down: a sine of the chosen wave width, plus an optional second harmonic at 1.7 times the phase speed (warp, which leans the crests), plus two short, fast sines (turbulence, for chop). Back layers are 40% flatter, run 0.6 radians behind the one in front and fade with the depth setting. The points are joined with quadratic curves through their midpoints and closed along the bottom. The phase moves one full cycle every 8 seconds at speed 1, the same rate as the original GSAP ticker.

Accessibility and performance

The SVG is decorative and has a text label. With prefers-reduced-motion it draws one still frame and never animates. The motion is a slow sideways roll with nothing flashing. Nothing needs keyboard input.

A frame rebuilds one short path string per layer, at most six paths of 49 points, and sets it as the d attribute. The loop stops at speed 0, while the panel is off screen and in hidden tabs.

SectionAnimated SVG backgrounds
TechniquesParallax and depth
StackVanilla JS, SVG
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byWave Generator by Enrichr on CodePen. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. Depth map shaderA photo and its depth map drawn by a WebGL shader, so the near parts of the picture move more than the far ones as the pointer moves. Cutout collage gridAn editorial collage built with CSS grid and clip-path shapes over generated art, with a gentle depth drift on pointer move.

FAQ

Can I use it as a section divider?

Yes. Give the element the height you want and set the wave colour to the colour of the next section, so the waves look like the top edge of it. The wave strip height setting keeps the wave size fixed however tall the panel is.

What does roundness do?

At 100 the corners between sample points are fully rounded, which is the original curve. Lower values shorten the rounding toward each point until 0 gives straight segments and a crystal, low-poly look. In the original pen any value above 0 drew the same fully rounded curve.

Why are the back layers drawn on top?

The original paints the front, fully opaque layer first and the fainter back layers over it, which tints the crests where they overlap. That order is kept so the look matches.

Does it need GSAP?

No. The original used a GSAP tween only as a ticker for the phase; here it is a requestAnimationFrame loop with the same rate.