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 "Ripple grid" for my site using the Enrichr Library element at https://enrichr.dev/elements/ripple-grid.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/ripple-grid.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:
- text: Move, click, ripple
- shape: dots
- cell: 24
- speed: 1
- accent: #e6ff55
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/ripple-grid.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-rpg">
<div class="ef-rpg__wrap">
<canvas class="ef-rpg__cv" aria-hidden="true"></canvas>
<svg width="0" height="0" aria-hidden="true" focusable="false" style="position:absolute">
<symbol id="rpg-puzzlePiece1" viewBox="1513.7 1453.9 322.3 322.3"><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"/></symbol>
<symbol id="rpg-puzzlePiece4" viewBox="1423.36 860.8 503.07 322.3"><path fill="currentColor" d="M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"/></symbol>
<symbol id="rpg-puzzlePiece8" viewBox="1513.7 166.86 322.3 503.06"><path fill="currentColor" d="M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-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.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 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.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"/></symbol>
</svg>
<div class="ef-rpg__art" aria-hidden="true">
<svg class="ef-rpg__pz ef-rpg__pz--a" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet"><use href="#rpg-puzzlePiece1" width="1" height="1"/></svg>
<svg class="ef-rpg__pz ef-rpg__pz--b" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet"><use href="#rpg-puzzlePiece4" width="1" height="1"/></svg>
<svg class="ef-rpg__pz ef-rpg__pz--c" viewBox="0 0 1 1" preserveAspectRatio="xMidYMid meet"><use href="#rpg-puzzlePiece8" width="1" height="1"/></svg>
</div>
<p class="ef-rpg__txt"></p>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-rpg { width: 100%; height: 100%; min-height: 18rem; }
.ef-rpg__wrap { position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 18rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; color: #fbf8f1; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; isolation: isolate; touch-action: pan-y; }
.ef-rpg__cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; }
.ef-rpg__art { position: absolute; inset: 0; pointer-events: none; }
.ef-rpg__pz { position: absolute; display: block; opacity: 0.92; filter: drop-shadow(0 10px 16px rgba(0, 0, 0, 0.5)); animation: ef-rpg-float 8s ease-in-out infinite; transform: rotate(var(--r, 0deg)); }
.ef-rpg__pz--a { width: 4.6rem; height: 4.6rem; right: 14%; bottom: 16%; --r: 11deg; color: #e6ff55; }
.ef-rpg__pz--b { width: 7.4rem; height: 4.8rem; left: 8%; top: 12%; --r: -8deg; color: #fbf8f1; animation-delay: -3s; }
.ef-rpg__pz--c { width: 3.6rem; height: 5.6rem; right: 8%; top: 10%; --r: 16deg; color: #2f7d5b; animation-delay: -5.5s; }
.ef-rpg__txt { position: absolute; left: 1.3rem; bottom: 1.1rem; margin: 0; font-size: 1.1rem; font-weight: 800; letter-spacing: -0.01em; color: #fbf8f1; text-shadow: 0 2px 12px rgba(12, 30, 24, 0.9); pointer-events: none; }
.ef-rpg__txt:empty { display: none; }
@keyframes ef-rpg-float { 0%, 100% { translate: 0 0; } 50% { translate: 0 -0.6rem; } }
@media (prefers-reduced-motion: reduce) { .ef-rpg__pz { animation: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Ripple grid: a canvas 2D field of dots or squares. Moving the pointer sends small circular ripples out through the grid, a click sends
// a big one; cells scale up and shift from deep green toward lime and cream as the wave passes. Reduced motion draws one still frame.
// Pauses off-screen. mount(root, { text, cell (14-48 px), shape ("dots"|"squares"), speed (0.4-2), strength (0.3-1.5), accent })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-rpg__wrap");
var cv = root.querySelector(".ef-rpg__cv");
var txt = root.querySelector(".ef-rpg__txt");
var ctx = cv.getContext("2d");
var opts = {}, W = 0, H = 0, dpr = 1, raf = 0, visible = true, dead = false;
var ripples = [], last = 0, lastSpawn = { x: -999, y: -999, t: 0 };
var BASE = [10, 76, 54], MID = [47, 125, 91], LIME = [198, 242, 107], CREAM = [251, 248, 241];
function hex(h) { h = h.replace("#", ""); if (h.length === 3) h = h.replace(/(.)/g, "$1$1"); var n = parseInt(h, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
function size() {
var r = wrap.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = Math.max(1, r.width); H = Math.max(1, r.height);
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
}
function spawn(x, y, amp, now) { ripples.push({ x: x, y: y, t: now, a: amp }); if (ripples.length > 14) ripples.shift(); }
function render(now) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "#0c1e18"; ctx.fillRect(0, 0, W, H);
var cs = opts.cell, cols = Math.ceil(W / cs) + 1, rows = Math.ceil(H / cs) + 1;
var ox = (W - (cols - 1) * cs) / 2, oy = (H - (rows - 1) * cs) / 2;
var acc = hex(opts.accent), sp = 260 * opts.speed, wid = cs * 2.2, life = 1.9 / Math.max(0.5, opts.speed * 0.8);
var live = [];
for (var k = 0; k < ripples.length; k++) {
var age = (now - ripples[k].t) / 1000;
if (age < life) live.push({ x: ripples[k].x, y: ripples[k].y, r: age * sp, f: Math.pow(1 - age / life, 1.5) * ripples[k].a * opts.strength });
}
if (!reduce) ripples = ripples.filter(function (r) { return (now - r.t) / 1000 < life; });
var sq = opts.shape === "squares";
for (var j = 0; j < rows; j++) for (var i = 0; i < cols; i++) {
var x = ox + i * cs, y = oy + j * cs, w = 0;
for (var m = 0; m < live.length; m++) {
var p = live[m], dx = x - p.x, dy = y - p.y, d = Math.sqrt(dx * dx + dy * dy) - p.r;
w += p.f * Math.exp(-(d * d) / (wid * wid));
}
if (w > 1.4) w = 1.4;
var col = w < 0.6 ? mix(BASE, MID, w / 0.6) : mix(mix(MID, acc, Math.min(1, (w - 0.6) / 0.4)), CREAM, Math.max(0, (w - 1) / 0.4) * 0.8);
var rad = cs * (0.12 + w * 0.3);
ctx.fillStyle = "rgb(" + (col[0] | 0) + "," + (col[1] | 0) + "," + (col[2] | 0) + ")";
if (sq) { ctx.fillRect(x - rad, y - rad, rad * 2, rad * 2); }
else { ctx.beginPath(); ctx.arc(x, y, rad, 0, 6.2832); ctx.fill(); }
}
return live.length;
}
function frame(now) {
raf = 0;
if (dead || !visible) return;
render(now);
raf = requestAnimationFrame(frame);
}
function start() { if (!raf && !reduce && !dead && visible) raf = requestAnimationFrame(frame); }
function stop() { if (raf) { cancelAnimationFrame(raf); raf = 0; } }
function still() {
if (!reduce) return;
// one frozen frame: a ripple caught mid-flight near the centre
var t = performance.now();
ripples = [{ x: W * 0.42, y: H * 0.45, t: t - 700, a: 1 }, { x: W * 0.72, y: H * 0.7, t: t - 1100, a: 0.8 }];
render(t); ripples = [];
}
function pos(e) { var r = wrap.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
function onMove(e) {
if (reduce) return;
var p = pos(e), now = performance.now();
var d = Math.hypot(p.x - lastSpawn.x, p.y - lastSpawn.y);
if (d > opts.cell * 1.6 && now - lastSpawn.t > 60) { spawn(p.x, p.y, 0.55, now); lastSpawn = { x: p.x, y: p.y, t: now }; }
}
function onDown(e) { if (reduce) return; var p = pos(e); spawn(p.x, p.y, 1.5, performance.now()); }
var io = new IntersectionObserver(function (es) { visible = es[0].isIntersecting; if (visible) start(); else stop(); });
var ro = new ResizeObserver(function () { size(); still(); });
cv.addEventListener("pointermove", onMove);
cv.addEventListener("pointerdown", onDown);
io.observe(wrap); ro.observe(wrap);
function update(next) {
opts = Object.assign({ text: "Move, click, ripple", cell: 24, shape: "dots", speed: 1, strength: 1, accent: "#e6ff55" }, opts, next || {});
opts.cell = Math.max(14, Math.min(48, +opts.cell));
opts.speed = Math.max(0.4, Math.min(2, +opts.speed));
opts.strength = Math.max(0.3, Math.min(1.5, +opts.strength));
txt.textContent = opts.text;
size();
if (reduce) still(); else if (!raf) render(performance.now());
}
update(o);
if (!reduce) spawn(W * 0.5, H * 0.5, 1.2, performance.now());
start();
return {
update: update,
destroy: function () {
dead = true; stop();
cv.removeEventListener("pointermove", onMove);
cv.removeEventListener("pointerdown", onDown);
io.disconnect(); ro.disconnect(); ripples = [];
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"text":"Move, click, ripple","shape":"dots","cell":24,"speed":1,"accent":"#e6ff55"});How it works
The canvas is redrawn each frame as a grid of cells. Each ripple stores its origin and start time; its ring radius is age times speed. For every cell the script measures the distance to the ring and adds a Gaussian bump centred on it, scaled by a fade that falls with age. Several ripples add together. The summed value drives cell radius (small dot to large) and colour (deep green, through mid green, to the wave colour, then cream at the peak). Moving the pointer spawns a small ripple every couple of cells of travel; a click spawns a large one. Old ripples are dropped once they have faded.
Accessibility and performance
The canvas is decorative and hidden from assistive tech. With prefers-reduced-motion the grid draws one still frame with ripples caught mid-flight and ignores the pointer. The effect is pointer-only, so keep real content or actions outside the canvas.
Cost is cells times live ripples per frame, capped at 14 ripples; a 520px box at 24px cells is about 500 cells. Larger cells are cheaper. It pauses off-screen and caps the pixel ratio at 2.
| Section | Background |
|---|---|
| Techniques | Pointer interaction |
| Stack | Canvas 2D, Vanilla JS, CSS |
| 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 | Ripple Grid (React Bits backgrounds), general effect only 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
How do I make the waves travel further?
Raise Wave speed. The fade length shortens slightly as speed goes up, so a lower value gives longer, calmer waves.
Can the shapes be something else?
Yes. The draw step is a single arc or fillRect call per cell; replace it with any path.
Does it work on touch?
Tapping sends a ripple. Dragging sends smaller ones, and the page can still scroll vertically.