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 "Gradient waves background" for my site using the Enrichr Library element at https://enrichr.dev/elements/gradient-waves.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/gradient-waves.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:
- accent: #e6ff55
- bands: 5
- speed: 0.8
- amplitude: 1
- pointer: 0.7
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/gradient-waves.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-gw">
<div class="ef-gw__wrap">
<canvas class="ef-gw__cv" aria-hidden="true"></canvas>
<svg class="ef-gw__piece" viewBox="1513.7 1453.9 322.3 322.3" aria-hidden="true" focusable="false"><path fill="currentColor" d="M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"/></svg>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-gw { width: 100%; height: 100%; }
.ef-gw__wrap { position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 18rem; border-radius: 1rem; overflow: hidden; background: #0c1e18; touch-action: pan-y; }
.ef-gw__cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ef-gw__piece { position: absolute; left: 0; top: 0; width: 3.6rem; height: 3.6rem; color: var(--ef-acc, #e6ff55); pointer-events: none; will-change: transform; filter: drop-shadow(0 6px 8px rgba(0, 0, 0, 0.35)); }JavaScript
// Enrichr Library · https://enrichr.dev
// Gradient waves: layered, flowing sine-wave bands filled with green-to-lime gradients on a <canvas> 2D context. The pointer bulges the
// bands near it (amplitude) and slides their phase sideways. A puzzle piece rides the front wave. Reduced motion draws one still frame.
// mount(root, { background, accent, bands (3-8), speed (0-2), amplitude (0.3-2), pointer (0-1) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-gw__wrap");
var cv = root.querySelector(".ef-gw__cv");
var piece = root.querySelector(".ef-gw__piece");
var ctx = cv.getContext("2d");
var opts = {}, w = 0, h = 0, dpr = 1, t = 2.4, last = 0, raf = 0, vis = true, dead = false;
var px = 0.5, py = 0.5, tpx = 0.5, tpy = 0.5, act = 0, tact = 0;
var offs = [];
var RAMP = ["#005236", "#0a6b46", "#2f7d5b", "#c6f26b"];
function rgb(c) { var n = parseInt(String(c).replace("#", ""), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
function ramp(k, a) {
var st = RAMP.concat([opts.accent]).map(rgb);
var f = Math.max(0, Math.min(0.9999, k)) * (st.length - 1), i = Math.floor(f), m = f - i, A = st[i], B = st[i + 1];
return "rgba(" + Math.round(A[0] + (B[0] - A[0]) * m) + "," + Math.round(A[1] + (B[1] - A[1]) * m) + "," + Math.round(A[2] + (B[2] - A[2]) * m) + "," + a + ")";
}
function band(i, n) {
var k = n > 1 ? i / (n - 1) : 0;
var boost = act * opts.pointer;
return {
k: k, i: i,
base: h * (0.3 + 0.52 * k),
amp: h * (0.045 + 0.04 * k) * opts.amplitude,
f: 0.9 + ((i * 37) % 5) * 0.14,
ph: t * opts.speed * (i % 2 ? 1 : -0.8) * (0.7 + k * 0.6) + i * 1.1 + (px - 0.5) * boost * 4
};
}
function yAt(b, x) {
var u = x / w, TAU = Math.PI * 2;
var s = Math.sin(u * TAU * b.f + b.ph) + 0.45 * Math.sin(u * TAU * b.f * 1.9 - b.ph * 0.8 + b.i * 1.7);
var near = Math.exp(-Math.pow(u - px, 2) / 0.05) * Math.exp(-Math.pow(b.base / h - py, 2) / 0.1);
return b.base + s * b.amp * (1 + near * act * opts.pointer * 2.2);
}
function draw() {
if (!w || !h) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var bg = ctx.createLinearGradient(0, 0, 0, h);
bg.addColorStop(0, opts.background); bg.addColorStop(1, "#005236");
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
var n = opts.bands, step = Math.max(4, w / 110), front = null;
for (var i = 0; i < n; i++) {
var b = band(i, n); front = b;
ctx.beginPath();
ctx.moveTo(0, yAt(b, 0));
for (var x = step; x < w + step; x += step) ctx.lineTo(x, yAt(b, x));
var edge = ctx.createLinearGradient(0, b.base - b.amp * 1.6, 0, Math.min(h, b.base + h * 0.42));
edge.addColorStop(0, ramp(b.k, 0.95));
edge.addColorStop(1, "rgba(12,30,24,0.9)");
ctx.lineTo(w + step, h); ctx.lineTo(0, h); ctx.closePath();
ctx.fillStyle = edge; ctx.fill();
ctx.beginPath();
ctx.moveTo(0, yAt(b, 0));
for (var x2 = step; x2 < w + step; x2 += step) ctx.lineTo(x2, yAt(b, x2));
ctx.lineWidth = 1.5;
ctx.strokeStyle = i === n - 1 ? "rgba(251,248,241,0.8)" : "rgba(251,248,241," + (0.1 + 0.22 * b.k).toFixed(2) + ")";
ctx.stroke();
}
if (front && piece) {
var s = piece.getBoundingClientRect().width || 58, cx = w * 0.74;
var y0 = yAt(front, cx - 6), y1 = yAt(front, cx + 6), ang = Math.atan2(y1 - y0, 12) * 57.3;
piece.style.transform = "translate(" + (cx - s / 2).toFixed(1) + "px," + (((y0 + y1) / 2) - s * 0.82).toFixed(1) + "px) rotate(" + (ang - 8).toFixed(1) + "deg)";
}
}
function size() {
var r = wrap.getBoundingClientRect();
w = r.width; h = r.height;
if (!w || !h) return;
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
draw();
}
function frame(ts) {
raf = 0;
if (!vis || dead) return;
var dt = Math.min(0.05, (ts - last) / 1000 || 0.016); last = ts;
t += dt;
px += (tpx - px) * Math.min(1, dt * 6); py += (tpy - py) * Math.min(1, dt * 6);
act += (tact - act) * Math.min(1, dt * 3);
draw();
raf = requestAnimationFrame(frame);
}
function start() { if (reduce || raf || !vis || dead) return; last = performance.now(); raf = requestAnimationFrame(frame); }
function stop() { if (raf) cancelAnimationFrame(raf); raf = 0; }
function mv(e) {
var r = wrap.getBoundingClientRect();
tpx = (e.clientX - r.left) / r.width; tpy = (e.clientY - r.top) / r.height; tact = 1;
if (reduce) { px = tpx; py = tpy; act = 1; draw(); }
}
function lv() { tact = 0; if (reduce) { act = 0; draw(); } }
wrap.addEventListener("pointermove", mv);
wrap.addEventListener("pointerleave", lv);
offs.push(function () { wrap.removeEventListener("pointermove", mv); wrap.removeEventListener("pointerleave", lv); });
var ro = null, io = null;
if (window.ResizeObserver) { ro = new ResizeObserver(size); ro.observe(wrap); } else { window.addEventListener("resize", size); offs.push(function () { window.removeEventListener("resize", size); }); }
if (window.IntersectionObserver) {
io = new IntersectionObserver(function (en) { vis = en[en.length - 1].isIntersecting; if (vis) start(); else stop(); });
io.observe(wrap);
}
function update(next) {
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", bands: 5, speed: 0.8, amplitude: 1, pointer: 0.7 }, opts, next || {});
opts.bands = Math.max(3, Math.min(8, Math.round(opts.bands)));
wrap.style.background = opts.background;
wrap.style.setProperty("--ef-acc", opts.accent);
size();
start();
}
update(o);
return {
update: update,
destroy: function () {
dead = true; stop();
if (ro) ro.disconnect();
if (io) io.disconnect();
offs.forEach(function (f) { f(); }); offs = [];
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","bands":5,"speed":0.8,"amplitude":1,"pointer":0.7});How it works
Each band is a vertical stack position with its own sine mix: one wave plus a faster harmonic, each with its own phase that advances with time. Every frame the script walks across the canvas in small steps, builds the band's top edge as a path, closes it to the bottom and fills it with a gradient that runs from the band colour down to the ink background, then strokes the top edge with a thin light line. Bands are painted back to front so the nearer ones cover the farther ones. The pointer position is smoothed and adds a Gaussian bump to the amplitude around it and shifts every band's phase sideways. The puzzle piece reads the front band's height and slope at one x position and is moved and rotated to sit on it.
Accessibility and performance
Purely decorative: the canvas and the puzzle piece are hidden from assistive tech. Put your text in a normal element above it and check contrast against the lighter bands at the bottom. With prefers-reduced-motion the waves are drawn once as a still frame and only redraw if the pointer moves.
One canvas, a few hundred line segments per band per frame, no per-pixel work. The pixel ratio is capped at 2, the loop pauses when the element is off screen, and it stops entirely on destroy(). Fewer bands or a smaller canvas lowers the cost further.
| Section | Background |
|---|---|
| Techniques | Pointer interaction |
| Stack | Vanilla JS, Canvas 2D |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Gradient Waves (React Bits) by React Bits on reactbits.dev. 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 change the colours?
The bands use the Enrichr greens up to the accent colour, and the background and accent are options. For a different ramp, edit the RAMP array at the top of the script.
How do I make it slower or calmer?
Lower speed and amplitude, and drop the band count to three or four.
Does it work inside a box with overflow hidden?
Yes. It sizes itself to its wrapper with a ResizeObserver and does not use fixed positioning.