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 "Dot wave pattern" for my site using the Enrichr Library element at https://enrichr.dev/elements/dot-wave-pattern.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/dot-wave-pattern.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:
- spacing: 40
- radius: 3
- speed: 1
- amplitude: 15
- frequency: 0.05
- interactive: 0
- force: 50
- primary: #e6ff55
- secondary: #fbf8f1
- 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/dot-wave-pattern.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-dwv"><svg class="ef-dwv__svg" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A grid of small dots swaying in a slow wave, with bands of colour drifting across it"></svg></div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-dwv { position: relative; width: 100%; min-height: 24rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; touch-action: pan-y; }
.ef-dwv .ef-dwv__svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }JavaScript
// Enrichr Library · https://enrichr.dev
// Dot wave pattern: an SVG grid of small circles that sway in a slow travelling wave while bands of colour drift across
// them. Every dot moves sideways by sin(row x frequency + time x 0.05) and up and down by cos(column x frequency + time x 0.05),
// times the wave amplitude, so rows and columns ripple out of step and the grid seems to breathe. Colour runs from the
// secondary colour to the primary and back along the diagonals, on a slower clock (time x 0.02). In interactive mode the
// pointer pushes nearby dots away and makes them swing wider, and every dot eases back to its place at 5% of the gap per
// frame (60 fps timing, made frame-rate independent). The grid covers the box plus two spare rows and columns each side.
// Reduced motion draws one still frame and ignores the pointer. The loop pauses off screen and in hidden tabs.
// mount(root, { spacing (px), radius (dot radius, px), speed, amplitude (px), frequency, interactive (1 on, 0 off),
// force (pointer reaction), primary, secondary, background })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var NS = "http://www.w3.org/2000/svg";
var wrap = root.querySelector(".ef-dwv");
var svg = root.querySelector(".ef-dwv__svg");
var opts = {}, raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0, time = 0;
var W = 0, H = 0, dots = [], rows = 0, cols = 0, sinRow = [], cosCol = [], fills = [];
var mx = -1000, my = -1000, c1 = [230, 255, 85], c2 = [251, 248, 241];
var MAX_DOTS = 6000;
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
function hex(h, d) { var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(m ? m[1] : d, 16); return [v >> 16, (v >> 8) & 255, v & 255]; }
// same maths as the original: a straight mix per channel, rounded, written as #rrggbb
function mix(a, b, f) {
var r = Math.round(a[0] + f * (b[0] - a[0])), g = Math.round(a[1] + f * (b[1] - a[1])), bl = Math.round(a[2] + f * (b[2] - a[2]));
return "#" + ((1 << 24) + (r << 16) + (g << 8) + bl).toString(16).slice(1);
}
function build() {
svg.textContent = "";
dots = [];
W = wrap.clientWidth; H = wrap.clientHeight;
if (W < 1 || H < 1) return;
var s = opts.spacing;
// never more than about 6,000 circles: widen the spacing on very large boxes or very small spacing values
while ((Math.ceil(W / s) + 4) * (Math.ceil(H / s) + 4) > MAX_DOTS) s *= 1.05;
cols = Math.ceil(W / s) + 4; rows = Math.ceil(H / s) + 4;
var startX = -s * 2, startY = -s * 2, frag = document.createDocumentFragment();
for (var i = 0; i < cols; i++) {
for (var j = 0; j < rows; j++) {
var cx = startX + i * s, cy = startY + j * s;
var c = document.createElementNS(NS, "circle");
c.setAttribute("cx", cx); c.setAttribute("cy", cy); c.setAttribute("r", opts.radius);
frag.appendChild(c);
dots.push({ el: c, baseX: cx, baseY: cy, originX: cx, originY: cy, col: i, row: j, fill: "" });
}
}
svg.appendChild(frag);
}
function frame(f) {
var amp = opts.amplitude, freq = opts.frequency, t = time * 0.05, i;
var live = !reduce && opts.interactive, force = opts.force, push = force / 25, ease = 1 - Math.pow(0.95, f);
for (i = 0; i < rows; i++) sinRow[i] = Math.sin(i * freq + t);
for (i = 0; i < cols; i++) cosCol[i] = Math.cos(i * freq + t);
// colour depends only on row + column, so work it out once per diagonal
for (i = 0; i < rows + cols; i++) {
var m = (Math.sin(i * 0.1 + time * 0.02) + 1) / 2;
fills[i] = m > 0.5 ? mix(c1, c2, (m - 0.5) * 2) : mix(c2, c1, m * 2);
}
for (i = 0; i < dots.length; i++) {
var d = dots[i], a = amp;
if (live) {
var dx = d.baseX - mx, dy = d.baseY - my, dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 200 && dist > 0) {
var inf = 1 - dist / 200;
a = amp + inf * force;
d.baseX += (dx / dist) * inf * push * f;
d.baseY += (dy / dist) * inf * push * f;
}
}
d.baseX += (d.originX - d.baseX) * ease;
d.baseY += (d.originY - d.baseY) * ease;
d.el.setAttribute("cx", (d.baseX + sinRow[d.row] * a).toFixed(2));
d.el.setAttribute("cy", (d.baseY + cosCol[d.col] * a).toFixed(2));
var fl = fills[d.row + d.col];
if (fl !== d.fill) { d.fill = fl; d.el.setAttribute("fill", fl); }
}
}
function loop(now) {
raf = requestAnimationFrame(loop);
var dt = Math.min(0.05, (now - last) / 1000 || 0); last = now;
if (!visible || document.hidden) return;
var f = dt * 60; // the original counts in frames at 60 fps
time += opts.speed * f;
frame(f);
}
function onMove(e) { var r = wrap.getBoundingClientRect(); mx = e.clientX - r.left; my = e.clientY - r.top; }
function onLeave() { mx = -1000; my = -1000; }
function update(partial) {
var prev = opts;
opts = Object.assign({ spacing: 40, radius: 3, speed: 1, amplitude: 15, frequency: 0.05, interactive: 0, force: 50, primary: "#e6ff55", secondary: "#fbf8f1", background: "#0c1e18" }, opts, partial || {});
opts.spacing = Math.max(4, num(opts.spacing, 40)); opts.radius = Math.max(0.5, num(opts.radius, 3));
opts.speed = Math.max(0, num(opts.speed, 1)); opts.amplitude = num(opts.amplitude, 15); opts.frequency = num(opts.frequency, 0.05);
opts.interactive = String(opts.interactive) === "1" || opts.interactive === true; opts.force = num(opts.force, 50);
c1 = hex(opts.primary, "e6ff55"); c2 = hex(opts.secondary, "fbf8f1");
wrap.style.background = opts.background;
if (destroyed) return;
if (!dots.length || prev.spacing !== opts.spacing) build();
else if (prev.radius !== opts.radius) for (var i = 0; i < dots.length; i++) dots[i].el.setAttribute("r", opts.radius);
frame(0);
}
update(o);
wrap.addEventListener("pointermove", onMove);
wrap.addEventListener("pointerleave", onLeave);
wrap.addEventListener("pointercancel", onLeave);
if (!reduce) {
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 || (wrap.clientWidth === W && wrap.clientHeight === H)) return;
build(); frame(0);
});
ro.observe(wrap);
}
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf);
wrap.removeEventListener("pointermove", onMove);
wrap.removeEventListener("pointerleave", onLeave);
wrap.removeEventListener("pointercancel", onLeave);
if (io) io.disconnect();
if (ro) ro.disconnect();
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"spacing":40,"radius":3,"speed":1,"amplitude":15,"frequency":0.05,"interactive":0,"force":50,"primary":"#e6ff55","secondary":"#fbf8f1","background":"#0c1e18"});How it works
The SVG holds one circle per grid point. The grid covers the box plus two spare rows and columns on every side, so the edges never gap when the wave moves the dots. A clock counts frames at 60 a second, times the speed. Each dot moves sideways by the sine of (row times frequency plus clock times 0.05) and up and down by the cosine of (column times frequency plus clock times 0.05), times the amplitude, so rows and columns swing out of step. Colour follows the sine of (0.1 times row plus column, plus clock times 0.02): at the midpoint a dot is the primary colour and at either end the secondary, so diagonal bands of colour drift slowly across. With the pointer reaction on, dots within 200 pixels of the pointer are pushed away by the force divided by 25 each frame, more the closer they are, and their swing grows by up to the force. Every dot eases back 5% of the way to its place each frame.
Accessibility and performance
The SVG is decorative and has a text label. With prefers-reduced-motion it draws one still frame and ignores the pointer. The movement is slow and small at the default settings. The pointer reaction is a visual extra; nothing needs the keyboard.
Sines and colours are worked out once per row, column and diagonal rather than per dot, and a dot's fill is only rewritten when its colour changes. At the default spacing a 1280 by 720 box has under 900 circles. The grid is capped near 6,000 circles, widening the spacing past that. The loop pauses off screen or in a hidden tab.
| Section | Animated SVG backgrounds |
|---|---|
| Techniques | Pointer interaction |
| 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 | Animated SVG Dot Pattern 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
How is it different from Dot matrix pulse?
Dot matrix pulse is a canvas of glowing dots that swell and fade in place. This one is plain SVG circles that keep their size and sway in a wave, with colour bands drifting across, and it can react to the pointer.
Can I put content on top of it?
Yes. Give .ef-dwv a height and place your content over it with position: absolute, or stack both in one grid cell. With the pointer reaction on, content on top takes the pointer events, so keep the dots visible around it.
How do I turn on the pointer reaction?
Set Pointer reaction to On. Dots near the pointer then scatter and swing wider, and drift back when it leaves. Reaction force sets how hard they are pushed.
Why SVG and not canvas?
The original is SVG, and at normal spacing a few hundred circles move smoothly as plain attributes. For very dense grids, spacing under about 15 pixels on a large screen, a canvas would be lighter.