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.
| Section | Animated SVG backgrounds |
|---|---|
| Techniques | Parallax and depth |
| Stack | Vanilla JS, SVG |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Wave 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.
More elements
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.