Pointer Swarm
A swarm of glowing particles streams after the pointer like a shoal of fish. Move quickly and it stretches into a ribbon; stop and it overshoots, then curls round the pointer in loose eddies. On a phone it follows your finger.
Lead the swarm
How to use it
- Paste the snippet just before
</head>. - Add
data-pointer-swarmto a section with a dark background. Adddata-autoplayso the swarm chases a wandering point whenever nobody is pointing, which keeps it alive on phones.
Options
Add any of these attributes to the same element.
data-autoplay | The swarm chases a wandering point by itself when idle. Without it, it gathers in the middle. |
data-density | Particles per 100 × 100 px. Default 6. |
data-speed | How fast they fly. Default 1. |
data-colors | Comma-separated colours, from the slowest particles to the fastest. |
It’s drawn on a transparent canvas behind your content and paused when off screen. With reduced motion you see the swarm gathered, still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Pointer Swarm: a swarm of glowing particles streams after the pointer
// (or your finger) like a shoal of fish. Move quickly and it stretches out
// into a ribbon; stop and it overshoots, then curls round the pointer in
// loose eddies. Drawn on a canvas behind your content, and paused when off
// screen.
// Usage: <section data-pointer-swarm>…</section>
// Optional: data-autoplay (the swarm chases a wandering point by itself
// when nobody is pointing), data-density (particles per 100 × 100 px,
// default 6), data-speed (how fast they fly, default 1), data-colors
// (comma-separated, from slow to fast).
var DEFAULTS = { density: 6, speed: 1, colors: ['#22d3ee', '#38bdf8', '#818cf8', '#c084fc', '#f0abfc'] };
var css =
'[data-pointer-swarm]{position:relative;isolation:isolate;overflow:hidden;-webkit-tap-highlight-color:transparent;touch-action:pan-y}' +
'.swm-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);
// Smooth value noise, used to stir small eddies into the swarm.
var perm = [];
for (var i = 0; i < 512; i++) perm[i] = Math.random();
function hash(x, y) { return perm[((x * 73856093) ^ (y * 19349663)) & 511]; }
function noise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
var u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
var a = hash(xi, yi), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'swm-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var density = d.density !== undefined ? +d.density : DEFAULTS.density;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
var w = 0, h = 0, dpr = 1, t = 0, scale = 1;
var parts = [], target = { x: 0, y: 0 }, pointerUntil = -1;
function spawn(p) {
p.x = Math.random() * w;
p.y = Math.random() * h;
p.vx = 0; p.vy = 0;
p.px = p.x; p.py = p.y;
// Each one likes to circle at its own distance, mostly the same way round.
p.r = (14 + Math.pow(Math.random(), 1.5) * 90) * scale;
p.spin = Math.random() < 0.85 ? 1 : -1;
p.agility = 0.7 + Math.random() * 0.6;
return p;
}
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
var ow = w, oh = h;
w = el.clientWidth;
h = el.clientHeight;
scale = Math.max(0.55, Math.min(1.2, Math.min(w, h * 1.5) / 560));
canvas.width = w * dpr;
canvas.height = h * dpr;
if (!w || !h) return;
if (!ow) { target.x = w / 2; target.y = h / 2; }
else { parts.forEach(function (p) { p.x *= w / ow; p.y *= h / oh; p.px = p.x; p.py = p.y; }); }
var n = Math.round(Math.min(900, Math.max(160, (w * h) / 10000 * density)));
while (parts.length < n) parts.push(spawn({}));
parts.length = n;
}
function wander() {
var k = t * 0.00042;
target.x = w * (0.5 + 0.28 * Math.sin(k) + 0.06 * Math.sin(k * 3.1 + 2));
target.y = h * (0.5 + 0.24 * Math.sin(k * 1.6 + 1) + 0.05 * Math.cos(k * 4.3));
}
// Velocities in px per ms.
function step(dt) {
t += dt;
if (t > pointerUntil) {
if (autoplay) wander();
else { target.x += (w / 2 - target.x) * 0.02; target.y += (h / 2 - target.y) * 0.02; }
}
var maxV = 0.55 * speed * scale, orbit = 0.16 * speed * scale;
var ease = 1 - Math.pow(0.955, dt / 16);
var ns = 0.012 / scale, tt = t * 0.0002;
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
p.px = p.x; p.py = p.y;
var dx = target.x - p.x, dy = target.y - p.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
var nx = dx / dist, ny = dy / dist;
// In towards its favourite distance, and round the pointer.
var pull = Math.max(-0.5, Math.min(1, (dist - p.r) / (120 * scale))) * maxV;
var vx = nx * pull - ny * orbit * p.spin;
var vy = ny * pull + nx * orbit * p.spin;
// Little eddies from a drifting noise field.
var qx = p.x * ns, qy = p.y * ns, e = 0.35;
vx += (noise(qx + tt, qy + e) - noise(qx + tt, qy - e)) * 0.35 * speed * scale;
vy -= (noise(qx + e, qy - tt) - noise(qx - e, qy - tt)) * 0.35 * speed * scale;
var k = ease * p.agility;
p.vx += (vx - p.vx) * k;
p.vy += (vy - p.vy) * k;
p.x += p.vx * dt;
p.y += p.vy * dt;
}
}
function draw(fresh) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (fresh) ctx.clearRect(0, 0, w, h);
else {
// Fade what's there a little each frame, leaving short trails.
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0,0,0,.24)';
ctx.fillRect(0, 0, w, h);
}
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
var buckets = colors.length, top = 0.5 * speed * scale;
for (var b = 0; b < buckets; b++) {
ctx.beginPath();
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
var v = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
var k = Math.min(buckets - 1, Math.floor((v / top) * buckets));
if (k !== b) continue;
ctx.moveTo(fresh ? p.x - p.vx * 30 : p.px, fresh ? p.y - p.vy * 30 : p.py);
ctx.lineTo(p.x + 0.01, p.y);
}
ctx.strokeStyle = colors[b];
ctx.globalAlpha = 0.8;
ctx.lineWidth = 1.6 + b * 0.2;
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
resize();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(true); }).observe(el);
// Let it gather into a swarm before it's first seen.
for (var warm = 0; warm < 150; warm++) step(16);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
draw(true);
return;
}
function point(e, hold) {
var r = el.getBoundingClientRect();
target.x = e.clientX - r.left;
target.y = e.clientY - r.top;
pointerUntil = t + hold;
}
el.addEventListener('pointermove', function (e) { point(e, e.pointerType === 'mouse' ? 1500 : 2500); });
el.addEventListener('pointerdown', function (e) { point(e, 2500); });
el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') pointerUntil = t + 400; });
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;
step(dt);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-pointer-swarm]').forEach(function (el) {
if (el._swm) return;
el._swm = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-pointer-swarm data-autoplay style="background: #050b18; color: #fff">
<h1>Follow me</h1>
</section>