Dot Grid Ripple
A quiet grid of dots sits behind a section. Move the pointer and the dots near it light up; click and a ring of light ripples out across the grid, nudging the dots aside as it passes. Left alone, it can ripple on its own.
Click anywhere
How to use it
- Paste the snippet just before
</head>. - Add
data-dot-grid-rippleto a section, hero or card.
Options
Add any of these attributes to the same element.
data-loop | Ripple on its own every couple of seconds, while the pointer is away. |
data-interval | Time between those ripples. Default 1800 ms. |
data-color | Dot colour. Default the text colour, faded. |
data-accent | Ripple colour. Default #a855f7. |
data-gap | Space between dots in px. Default 22. |
data-size | Dot radius in px. Default 1.4. |
It’s drawn on a canvas behind your content and paused when off screen. Clicks still reach your links and buttons. With reduced motion the dots stay still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Dot Grid Ripple: a quiet grid of dots behind a section. Move the pointer
// and the dots near it swell; click or tap and a ring of light ripples out
// across the grid, nudging dots outward as it passes. With data-loop,
// ripples also start on their own. Drawn on a canvas behind your content,
// and paused when off screen.
// Usage: <section data-dot-grid-ripple data-loop>…</section>
// Optional: data-color (dots, default the text colour), data-accent
// (ripple colour, default #a855f7), data-gap (px between dots, default
// 22), data-size (dot radius in px, default 1.4), data-loop (ripple on its
// own), data-interval (ms between those ripples, default 1800).
var DEFAULTS = { accent: '#a855f7', gap: 22, size: 1.4, interval: 1800 };
var css =
'[data-dot-grid-ripple]{position:relative;isolation:isolate;overflow:hidden}' +
'.dg-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'dg-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var gap = +d.gap || DEFAULTS.gap;
var size = +d.size || DEFAULTS.size;
var accent = d.accent || DEFAULTS.accent;
var color = d.color || getComputedStyle(el).color;
var w = 0, h = 0, dpr = 1, dots = [];
var ripples = [];
var pointer = { x: -1e4, y: -1e4, on: 0 };
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
// Centre the grid so the margins match on every side.
dots = [];
var cols = Math.floor(w / gap), rows = Math.floor(h / gap);
var ox = (w - (cols - 1) * gap) / 2, oy = (h - (rows - 1) * gap) / 2;
for (var j = 0; j < rows; j++) for (var i = 0; i < cols; i++) dots.push(ox + i * gap, oy + j * gap);
draw();
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var lens = gap * 4.5;
// Dots at rest go in one batch; lit or moved dots are drawn after.
var lit = [];
ctx.fillStyle = color;
ctx.globalAlpha = 0.32;
ctx.beginPath();
for (var k = 0; k < dots.length; k += 2) {
var x = dots[k], y = dots[k + 1], glow = 0, dx, dy, dist;
for (var r = 0; r < ripples.length; r++) {
var rp = ripples[r];
dx = x - rp.x; dy = y - rp.y;
dist = Math.sqrt(dx * dx + dy * dy) || 1;
// A ring about two dots wide, fading as it grows.
var t = (dist - rp.radius) / (gap * 2.2);
if (t > -1 && t < 1) {
var f = (1 - t * t) * rp.strength;
glow = Math.max(glow, f);
x += (dx / dist) * f * gap * 0.45;
y += (dy / dist) * f * gap * 0.45;
}
}
if (pointer.on) {
dx = x - pointer.x; dy = y - pointer.y;
dist = Math.sqrt(dx * dx + dy * dy);
if (dist < lens) glow = Math.max(glow, (1 - dist / lens) * 0.75 * pointer.on);
}
if (glow > 0.02) { lit.push(x, y, glow); continue; }
ctx.moveTo(x + size, y);
ctx.arc(x, y, size, 0, Math.PI * 2);
}
ctx.fill();
ctx.fillStyle = accent;
for (var m = 0; m < lit.length; m += 3) {
ctx.globalAlpha = 0.3 + lit[m + 2] * 0.7;
ctx.beginPath();
ctx.arc(lit[m], lit[m + 1], size * (1 + lit[m + 2] * 2), 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
resize();
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var reach = function (x, y) { return Math.hypot(Math.max(x, w - x), Math.max(y, h - y)) + gap * 2; };
// Each ripple is an anime.js tween of a radius and a strength.
function ripple(x, y, strength) {
var rp = { x: x, y: y, radius: 0, strength: strength };
ripples.push(rp);
var far = reach(x, y);
anime.animate(rp, {
radius: far,
// It stays bright for most of the way, then fades.
strength: { to: 0, ease: 'in(2)' },
duration: far * 3.2,
ease: 'out(1.3)',
onComplete: function () { ripples.splice(ripples.indexOf(rp), 1); }
});
}
// Redraw while something is moving; a still grid is drawn once.
var still = false;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
if (!ripples.length && !pointer.on) { if (still) return; still = true; } else still = false;
draw();
}
});
var hovering = false, last = 0;
var local = function (e) { var r = el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
el.addEventListener('pointermove', function (e) {
var p = local(e);
pointer.x = p[0]; pointer.y = p[1];
if (!hovering && e.pointerType !== 'touch') {
hovering = true;
anime.animate(pointer, { on: 1, duration: 300, ease: 'out(2)' });
}
// A faint ripple trails the pointer as it moves.
var now = performance.now();
if (now - last > 420) { last = now; ripple(p[0], p[1], 0.35); }
});
el.addEventListener('pointerleave', function () {
hovering = false;
anime.animate(pointer, { on: 0, duration: 400, ease: 'out(2)' });
});
el.addEventListener('pointerdown', function (e) {
var p = local(e);
ripple(p[0], p[1], 1);
});
var loop = d.loop !== undefined && d.loop !== 'false';
var auto = loop ? anime.createTimer({
duration: +d.interval || DEFAULTS.interval,
loop: true,
autoplay: false,
onLoop: function () {
if (hovering) return;
var R = anime.utils.random;
ripple(R(0.15, 0.85, 3) * w, R(0.2, 0.8, 3) * h, 1);
}
}) : null;
new IntersectionObserver(function (entries) {
var on = entries[0].isIntersecting;
[timer, auto].forEach(function (t) { if (t) { if (on) t.play(); else t.pause(); } });
if (on && auto && !ripples.length) ripple(w / 2, h / 2, 1);
}).observe(el);
}
function init() {
document.querySelectorAll('[data-dot-grid-ripple]').forEach(function (el) {
if (el._dg) return;
el._dg = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-dot-grid-ripple data-loop style="background: #0f0a1e; color: #fff">
<h1>Hello</h1>
</section>