Particle Repel
A field of dots scatters away from the pointer like filings from a magnet, swelling and lighting up as they’re pushed, then springs back into place once it moves on. Tap or click to send a shockwave through them.
Tap for a shockwave
How to use it
- Paste the snippet just before
</head>. - Add
data-particle-repelto a section, hero or card. Adddata-autoplayso a pretend pointer moves through it whenever nobody is pointing, which keeps it alive on phones.
Options
Add any of these attributes to the same element.
data-autoplay | A pretend pointer drifts through the dots when idle. |
data-gap | Space between dots, in px. Default 18. |
data-radius | How far the push reaches, in px. Default 90. |
data-strength | How hard they’re pushed. Default 1. |
data-color | Colour of resting dots. Default #a78bfa. |
data-active | Colour of dots that have been pushed well away. Default #f0abfc. |
It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion the dots sit still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Particle Repel: a field of dots scatters away from the pointer like iron
// filings from a magnet, swelling and lighting up as they're pushed, then
// springs back into place when it moves on. A tap or click sends a
// shockwave through them. With data-autoplay a pretend pointer drifts
// through the field whenever nobody is pointing, so it's alive on phones.
// Drawn on a transparent canvas behind your content, and paused when off
// screen.
// Usage: <section data-particle-repel>…</section>
// Optional: data-autoplay (a pretend pointer wanders through when idle),
// data-gap (space between dots in px, default 18), data-radius (how far
// the push reaches in px, default 90), data-strength (default 1),
// data-color (resting dot colour, default #a78bfa), data-active (colour of
// pushed dots, default #f0abfc).
var DEFAULTS = { gap: 18, radius: 90, strength: 1, color: '#a78bfa', active: '#f0abfc' };
var css =
'[data-particle-repel]{position:relative;isolation:isolate;overflow:hidden;-webkit-tap-highlight-color:transparent}' +
'.prp-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 = 'prp-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var gap = Math.max(6, +d.gap || DEFAULTS.gap);
var radius = +d.radius || DEFAULTS.radius;
var strength = d.strength !== undefined ? +d.strength : DEFAULTS.strength;
var color = d.color || DEFAULTS.color;
var active = d.active || DEFAULTS.active;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
var w = 0, h = 0, dpr = 1, t = 0;
var dots = [];
var ptr = { x: -9999, y: -9999, on: false, real: -1 };
var fake = { x: 0, y: 0 };
var reach = 0; // how present the pointer is, 0 to 1, so it eases in and out
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
// A slightly jittered grid, centred in the box.
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 r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
var hx = ox + c * gap + (Math.random() - 0.5) * gap * 0.35;
var hy = oy + r * gap + (Math.random() - 0.5) * gap * 0.35;
dots.push({ hx: hx, hy: hy, x: hx, y: hy, vx: 0, vy: 0, s: 0.8 + Math.random() * 0.5 });
}
}
}
function shockwave(x, y) {
var big = radius * 2.4;
for (var i = 0; i < dots.length; i++) {
var p = dots[i];
var dx = p.x - x, dy = p.y - y, dist = Math.sqrt(dx * dx + dy * dy) || 1;
if (dist > big) continue;
var k = (1 - dist / big);
p.vx += (dx / dist) * k * k * 22 * strength;
p.vy += (dy / dist) * k * k * 22 * strength;
}
}
function advance(dt) {
t += dt;
var s = dt / 16;
var real = t < ptr.real;
if (!real && autoplay) {
// A pretend pointer drifting in a lazy figure of eight.
var k = t * 0.00055;
fake.x = w * (0.5 + Math.sin(k) * 0.36);
fake.y = h * (0.5 + Math.sin(k * 2) * 0.28);
}
var on = real ? ptr.on : autoplay;
reach += ((on ? 1 : 0) - reach) * Math.min(1, dt * 0.006);
var px = real ? ptr.x : fake.x, py = real ? ptr.y : fake.y;
var spring = 1 - Math.pow(1 - 0.055, s), damp = Math.pow(0.84, s);
for (var i = 0; i < dots.length; i++) {
var p = dots[i];
if (reach > 0.01) {
var dx = p.x - px, dy = p.y - py;
var d2 = dx * dx + dy * dy;
if (d2 < radius * radius) {
var dist = Math.sqrt(d2) || 1;
var f = 1 - dist / radius;
var push = f * f * 4.2 * strength * reach * s;
p.vx += (dx / dist) * push;
p.vy += (dy / dist) * push;
}
}
p.vx += (p.hx - p.x) * spring;
p.vy += (p.hy - p.y) * spring;
p.vx *= damp;
p.vy *= damp;
p.x += p.vx * s;
p.y += p.vy * s;
}
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Three passes, from resting to fully pushed, each one path.
var levels = [[0, 3, color, 0.55, 1.3], [3, 12, color, 0.9, 1.9], [12, 1e9, active, 1, 2.6]];
for (var l = 0; l < levels.length; l++) {
var lv = levels[l];
ctx.beginPath();
for (var i = 0; i < dots.length; i++) {
var p = dots[i];
var dx = p.x - p.hx, dy = p.y - p.hy;
var off = Math.sqrt(dx * dx + dy * dy);
if (off < lv[0] || off >= lv[1]) continue;
var r = lv[4] * p.s + Math.min(1.6, off * 0.03);
ctx.moveTo(p.x + r, p.y);
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
}
ctx.fillStyle = lv[2];
ctx.globalAlpha = lv[3];
ctx.fill();
}
ctx.globalAlpha = 1;
}
resize();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
draw();
return;
}
function at(e) {
var r = el.getBoundingClientRect();
ptr.x = e.clientX - r.left;
ptr.y = e.clientY - r.top;
}
el.addEventListener('pointermove', function (e) {
at(e);
ptr.on = true;
ptr.real = t + (e.pointerType === 'mouse' ? 2000 : 1200);
});
el.addEventListener('pointerdown', function (e) {
at(e);
ptr.on = true;
ptr.real = t + 1600;
shockwave(ptr.x, ptr.y);
});
el.addEventListener('pointerleave', function () { ptr.on = false; ptr.real = t + 1500; });
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(40, now - last) : 16;
last = now;
advance(dt);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-particle-repel]').forEach(function (el) {
if (el._prp) return;
el._prp = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-particle-repel data-autoplay style="background: #120a24; color: #fff">
<h1>Push back</h1>
</section>