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 "Rainbow shape wave" for my site using the Enrichr Library element at https://enrichr.dev/elements/rainbow-shape-wave.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/rainbow-shape-wave.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:
- gap: 40
- radius: 30
- speedIn: 0.5
- speedOut: 0.6
- restScale: 0.09
- minHover: 1
- maxHover: 3
- waveSpeed: 1200
- waveWidth: 180
- auto: 5
- palette: rainbow
- background: #0c1e18
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/rainbow-shape-wave.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-rsw"><canvas class="ef-rsw__c" role="img" aria-label="A field of tiny rainbow stars, dots and pills that swell into full shapes as a ring-shaped wave rolls out across them, and bloom around the pointer"></canvas></div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-rsw { position: relative; width: 100%; min-height: 24rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; touch-action: pan-y; }
.ef-rsw .ef-rsw__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }JavaScript
// Enrichr Library · https://enrichr.dev
// Rainbow shape wave: a grid of tiny coloured shapes (circles, pills and 4 to 10 point stars, some with a soft two-colour
// gradient) that rest as specks. A ring-shaped wave rolls out from a point at a fixed speed; each shape swells to its own
// random size as the ring passes (a sine bump across the ring's width) and eases back after it. Shapes near the pointer
// bloom too, with a smoothstep falloff, and pick a new size, angle and star shape each time the pointer reaches them.
// Growing and shrinking are exponential eases that cover 95% of the gap in the "grow" and "shrink" times. A click sends a
// wave from that point; a wave also starts from the centre when the element first comes into view and, if set, on a timer.
// Elements inside the root with a data-shape-mask attribute keep shapes from showing under them, except while a wave runs.
// Reduced motion draws one still frame with the wave part way out and never animates. Pauses off screen and in hidden tabs.
// mount(root, { gap (px between shapes), radius (pointer reach, % of the short side), speedIn (grow time, s),
// speedOut (shrink time, s), restScale (size at rest), minHover / maxHover (smallest / largest pop size),
// waveSpeed (px per s), waveWidth (px), auto (s between centre waves, 0 = off),
// palette ("rainbow" | "enrichr"), background })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-rsw");
var canvas = root.querySelector(".ef-rsw__c");
var ctx = canvas.getContext("2d");
var PALETTES = {
rainbow: [
{ type: "solid", value: "#22c55e" }, { type: "solid", value: "#06b6d4" }, { type: "solid", value: "#f97316" },
{ type: "solid", value: "#ef4444" }, { type: "solid", value: "#facc15" }, { type: "solid", value: "#ec4899" },
{ type: "solid", value: "#9ca3af" }, { type: "solid", value: "#a78bfa" }, { type: "solid", value: "#60a5fa" },
{ type: "solid", value: "#34d399" },
{ type: "gradient", stops: ["#6366f1", "#3b82f6"] }, { type: "gradient", stops: ["#06b6d4", "#6366f1"] },
{ type: "gradient", stops: ["#22c55e", "#06b6d4"] }, { type: "gradient", stops: ["#f97316", "#ef4444"] },
{ type: "gradient", stops: ["#8b5cf6", "#06b6d4"] }, { type: "gradient", stops: ["#3b82f6", "#8b5cf6"] },
{ type: "gradient", stops: ["#34d399", "#3b82f6"] }
],
enrichr: [
{ type: "solid", value: "#e6ff55" }, { type: "solid", value: "#d2eb3c" }, { type: "solid", value: "#fbf8f1" },
{ type: "solid", value: "#1a7a4c" }, { type: "solid", value: "#126e45" }, { type: "solid", value: "#e54c1e" },
{ type: "solid", value: "#e6ff55" }, { type: "solid", value: "#fbf8f1" }, { type: "solid", value: "#1a7a4c" },
{ type: "solid", value: "#d2eb3c" },
{ type: "gradient", stops: ["#e6ff55", "#1a7a4c"] }, { type: "gradient", stops: ["#fbf8f1", "#e6ff55"] },
{ type: "gradient", stops: ["#1a7a4c", "#126e45"] }, { type: "gradient", stops: ["#e54c1e", "#e6ff55"] },
{ type: "gradient", stops: ["#4b1a9a", "#e6ff55"] }, { type: "gradient", stops: ["#fbf8f1", "#1a7a4c"] },
{ type: "gradient", stops: ["#d2eb3c", "#126e45"] }
]
};
var SHAPE_TYPES = ["circle", "pill", "star", "star"];
// visible waits for the first observer report, so the opening wave plays on screen
var opts = {}, raf = 0, visible = !("IntersectionObserver" in window), io = null, ro = null, destroyed = false, last = 0;
var W = 1, H = 1, dpr = 1, shapes = [], size = 15.2, grads = [];
var pointer = null, activity = 0, waves = [], maskRects = [], frameCount = 0, maskUntil = 0;
var clock = 0, autoAcc = 0, started = false; // clock is in ms and only runs while the element is playing
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
function rnd(min, max) { return Math.random() * (max - min) + min; }
function rndInt(min, max) { return Math.floor(rnd(min, max + 1)); }
function smoothstep(t) { var c = Math.max(0, Math.min(1, t)); return c * c * (3 - 2 * c); }
// the pen's per-frame ease (1 - 0.05^(1 / (60 s))), written for any frame length: 95% of the gap is covered in s seconds
function ease(seconds, dt) { return seconds <= 0 ? 1 : 1 - Math.pow(0.05, dt / seconds); }
function palette() { return PALETTES[opts.palette] || PALETTES.rainbow; }
function starProps(s) { s.points = rndInt(4, 10); s.innerRatio = rnd(0.1, 0.5); }
function drawShape(s) {
var r;
ctx.beginPath();
if (s.type === "circle") { ctx.arc(0, 0, s.size / 1.5, 0, Math.PI * 2); }
else if (s.type === "pill") {
var h = s.size / 1.4, w = h * 0.48;
if (ctx.roundRect) ctx.roundRect(-w, -h, w * 2, h * 2, w);
else { ctx.moveTo(-w, -h + w); ctx.arc(0, -h + w, w, Math.PI, 0); ctx.lineTo(w, h - w); ctx.arc(0, h - w, w, 0, Math.PI); ctx.closePath(); }
} else {
for (var i = 0; i < s.points * 2; i++) {
var a = i * Math.PI / s.points - Math.PI / 2;
r = i % 2 === 0 ? s.size : s.size * s.innerRatio;
if (i === 0) ctx.moveTo(Math.cos(a) * r, Math.sin(a) * r); else ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r);
}
ctx.closePath();
}
ctx.fill();
}
// every shape has the same size, so each palette entry needs only one gradient object, made once and reused
function fill(ci) {
var c = palette()[ci];
if (c.type === "solid") return c.value;
if (!grads[ci]) {
var g = ctx.createRadialGradient(0, -size * 0.3, 0, 0, size * 0.3, size * 1.5);
g.addColorStop(0, c.stops[0]); g.addColorStop(1, c.stops[1]);
grads[ci] = g;
}
return grads[ci];
}
function build() {
var gap = Math.max(opts.gap, Math.sqrt(W * H / 6000)); // never more than about 6,000 shapes
var cols = Math.floor(W / gap), rows = Math.floor(H / gap);
var ox = (W - (cols - 1) * gap) / 2, oy = (H - (rows - 1) * gap) / 2, n = palette().length;
size = gap * 0.38; grads = []; shapes = [];
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
var type = SHAPE_TYPES[Math.floor(Math.random() * SHAPE_TYPES.length)];
var s = { x: ox + c * gap, y: oy + r * gap, type: type, ci: Math.floor(Math.random() * n), angle: rnd(0, Math.PI * 2),
size: size, scale: opts.restScale, maxScale: rnd(opts.minHover, opts.maxHover), hovered: false };
if (type === "star") starProps(s);
shapes.push(s);
}
}
}
function triggerWave(x, y) {
waves.push({ x: x === undefined ? W / 2 : x, y: y === undefined ? H / 2 : y, t: clock });
maskUntil = clock + Math.sqrt(W * W + H * H) / opts.waveSpeed * 1000; // masks open while the wave crosses
}
function draw(s) {
if (s.scale < opts.restScale * 0.15) return;
ctx.save();
ctx.translate(s.x, s.y); ctx.rotate(s.angle); ctx.scale(s.scale, s.scale);
ctx.fillStyle = fill(s.ci);
drawShape(s);
ctx.restore();
}
function clear() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = opts.background; ctx.fillRect(0, 0, W, H);
}
function frame(dt) {
var radius = Math.min(W, H) * (opts.radius / 100), rest = opts.restScale;
clear();
activity *= Math.pow(0.93, dt * 60);
frameCount++;
if (frameCount % 10 === 0) {
var box = wrap.getBoundingClientRect(), els = wrap.querySelectorAll("[data-shape-mask]");
maskRects = [];
for (var m = 0; m < els.length; m++) {
var b = els[m].getBoundingClientRect();
maskRects.push({ left: b.left - box.left, right: b.right - box.left, top: b.top - box.top, bottom: b.bottom - box.top });
}
}
var maxDist = Math.sqrt(W * W + H * H);
waves = waves.filter(function (w) { return (clock - w.t) / 1000 * opts.waveSpeed < maxDist + opts.waveWidth; });
var masking = clock >= maskUntil && maskRects.length, pad = opts.gap / 2, kIn = ease(opts.speedIn, dt), kOut = ease(opts.speedOut, dt);
for (var i = 0; i < shapes.length; i++) {
var s = shapes[i];
if (masking) {
var hidden = false;
for (var q = 0; q < maskRects.length; q++) {
var rc = maskRects[q];
if (s.x >= rc.left - pad && s.x <= rc.right + pad && s.y >= rc.top - pad && s.y <= rc.bottom + pad) { hidden = true; break; }
}
if (hidden) { s.scale += (0 - s.scale) * kOut; if (s.scale < 0.005) s.scale = 0; continue; }
}
var pin = 0;
if (pointer && activity > 0.001) {
var dx = s.x - pointer.x, dy = s.y - pointer.y;
pin = smoothstep(1 - Math.sqrt(dx * dx + dy * dy) / radius) * activity;
if (pin > 0.05 && !s.hovered) {
s.hovered = true; s.maxScale = rnd(opts.minHover, opts.maxHover); s.angle = rnd(0, Math.PI * 2);
if (s.type === "star") starProps(s);
} else if (pin <= 0.05) s.hovered = false;
} else s.hovered = false;
var win = 0;
for (var j = 0; j < waves.length; j++) {
var w = waves[j], wr = (clock - w.t) / 1000 * opts.waveSpeed;
var wdx = s.x - w.x, wdy = s.y - w.y;
var t = 1 - Math.abs(Math.sqrt(wdx * wdx + wdy * wdy) - wr) / opts.waveWidth;
if (t > 0) win = Math.max(win, Math.sin(Math.PI * t));
}
var target = Math.max(rest + pin * (s.maxScale - rest), rest + win * (s.maxScale - rest));
s.scale += (target - s.scale) * (target > s.scale ? kIn : kOut);
draw(s);
}
}
// reduced motion: one still frame with a wave ring 40% of the way across, every shape at its target size
function still() {
var cx = W / 2, cy = H / 2, wr = Math.sqrt(W * W + H * H) * 0.4, rest = opts.restScale;
clear();
for (var i = 0; i < shapes.length; i++) {
var s = shapes[i], d = Math.sqrt((s.x - cx) * (s.x - cx) + (s.y - cy) * (s.y - cy));
var t = 1 - Math.abs(d - wr) / opts.waveWidth, win = t > 0 ? Math.sin(Math.PI * t) : 0;
s.scale = rest + win * (s.maxScale - rest);
draw(s);
}
}
function loop(now) {
raf = requestAnimationFrame(loop);
var dt = Math.min(0.05, (now - last) / 1000 || 0); last = now;
if (!visible || document.hidden) return;
if (!started) { started = true; triggerWave(); }
clock += dt * 1000;
if (opts.auto > 0) { autoAcc += dt; if (autoAcc >= opts.auto) { autoAcc = 0; triggerWave(); } }
frame(dt);
}
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
var nw = Math.max(1, wrap.clientWidth), nh = Math.max(1, wrap.clientHeight);
canvas.width = Math.round(nw * dpr); canvas.height = Math.round(nh * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
grads = [];
if (nw !== W || nh !== H || !shapes.length) { W = nw; H = nh; build(); }
if (reduce) still(); else frame(0);
}
function update(partial) {
var prev = opts;
opts = Object.assign({ gap: 40, radius: 30, speedIn: 0.5, speedOut: 0.6, restScale: 0.09, minHover: 1, maxHover: 3,
waveSpeed: 1200, waveWidth: 180, auto: 5, palette: "rainbow", background: "#0c1e18" }, opts, partial || {});
opts.gap = Math.max(12, num(opts.gap, 40)); opts.radius = Math.max(1, num(opts.radius, 30));
opts.speedIn = Math.max(0, num(opts.speedIn, 0.5)); opts.speedOut = Math.max(0, num(opts.speedOut, 0.6));
opts.restScale = Math.max(0, num(opts.restScale, 0.09)); opts.minHover = Math.max(0, num(opts.minHover, 1));
opts.maxHover = Math.max(0, num(opts.maxHover, 3)); opts.waveSpeed = Math.max(20, num(opts.waveSpeed, 1200));
opts.waveWidth = Math.max(1, num(opts.waveWidth, 180)); opts.auto = Math.max(0, num(opts.auto, 5));
if (!PALETTES[opts.palette]) opts.palette = "rainbow";
wrap.style.background = opts.background;
if (destroyed || !shapes.length) return;
if (opts.gap !== prev.gap) build();
else {
if (opts.palette !== prev.palette) { grads = []; var n = palette().length; shapes.forEach(function (s) { s.ci = Math.floor(Math.random() * n); }); }
if (opts.minHover !== prev.minHover || opts.maxHover !== prev.maxHover) shapes.forEach(function (s) { s.maxScale = rnd(opts.minHover, opts.maxHover); });
}
if (reduce) still(); else frame(0);
}
function local(e) { var b = wrap.getBoundingClientRect(); return { x: e.clientX - b.left, y: e.clientY - b.top }; }
function onMove(e) { pointer = local(e); activity = 1; }
function onClick(e) { var p = local(e); triggerWave(p.x, p.y); }
update(o);
resize();
if (!reduce) {
wrap.addEventListener("pointermove", onMove);
wrap.addEventListener("click", onClick);
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
last = performance.now(); raf = requestAnimationFrame(loop);
}
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) resize(); }); ro.observe(wrap); }
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf);
wrap.removeEventListener("pointermove", onMove);
wrap.removeEventListener("click", onClick);
if (io) io.disconnect();
if (ro) ro.disconnect();
shapes = []; waves = []; grads = [];
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"gap":40,"radius":30,"speedIn":0.5,"speedOut":0.6,"restScale":0.09,"minHover":1,"maxHover":3,"waveSpeed":1200,"waveWidth":180,"auto":5,"palette":"rainbow","background":"#0c1e18"});How it works
The shapes sit on an even grid, centred in the box. Each one is a circle, a pill or a star with 4 to 10 points, has a random angle and colour (some colours are a two-tone radial gradient), and rests at 9% of its size. A wave is a ring that grows from its start point at a fixed speed; a shape inside the ring's width gets a sine bump, so it swells to its own random pop size as the ring passes and falls back after. Shapes near the pointer swell too, with a smoothstep falloff, and pick a new size, angle and star shape each time the pointer reaches them; the pointer's pull fades by 7% a frame once it stops. Growing and shrinking use an exponential ease that closes 95% of the gap in the grow or shrink time, worked out from the frame length so it runs the same at any refresh rate. A wave starts from the centre when the element first appears, from any click, and on the auto timer. Anything inside the root with a data-shape-mask attribute keeps shapes from showing under it, except while a wave is crossing.
Accessibility and performance
The canvas is decorative and has a text label. With prefers-reduced-motion it draws one still frame with the wave part way out and never animates. The pointer and click effects are decoration only; the timed wave shows the same motion without a mouse, and nothing needs the keyboard. Nothing flashes: each shape takes about half a second to grow.
One canvas, one draw per visible shape, no filters or shadows. Each gradient colour is made once and reused, because every shape shares one size. The grid is capped at about 6,000 shapes, the pixel ratio at 2, and the loop pauses off screen or in a hidden tab, with its clock stopped so waves pick up where they left off.
| Section | Background |
|---|---|
| Techniques | Pointer interaction, Stagger |
| Stack | Vanilla JS, Canvas |
| 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 | Shape Wave 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 put text on top of it?
Yes. Place your content inside .ef-rsw with position: relative and a z-index above the canvas. Add data-shape-mask to the text block and the shapes under it shrink away so the text stays readable; a passing wave still shows through for a moment, as in the original.
Can I change the colours?
Pick Rainbow or Enrichr greens and lime in the Colours setting, or edit the PALETTES list at the top of element.js. Each entry is either a solid colour or a pair of colours for a soft radial gradient.
How do I stop the automatic waves?
Set Auto wave every to 0. Then a wave plays once when the element appears and again on each click, exactly like the original pen.
Why canvas and not SVG?
A full-screen grid has several hundred shapes, each changing size every frame. Drawing them on one canvas is far cheaper than updating hundreds of SVG nodes.