Gravity Well
A glowing point pulls a cloud of particles into orbit. They whip round it in streaks, faster the closer they get, slowly spiral in and vanish into the core with a flare, while new ones drift in from the edges. The well follows your pointer or finger.
Move to steer
How to use it
- Paste the snippet just before
</head>. - Add
data-gravity-wellto a section with a dark background. Adddata-autoplayto have the well wander by itself whenever nobody is pointing.
Options
Add any of these attributes to the same element.
data-autoplay | The well drifts around by itself when idle. Without it, it rests in the middle. |
data-density | Particles per 100 × 100 px. Default 8. |
data-pull | Strength of gravity. Higher means faster orbits. 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. On a phone the well follows your finger and wanders off again a moment after you let go. With reduced motion you see a still frame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Gravity Well: a glowing point pulls a cloud of particles into orbit.
// They swirl around it in streaks, slowly spiral inwards and vanish into
// the middle with a flash, while new ones drift in from the edges. The
// well follows the pointer (or your finger), and with data-autoplay it
// wanders by itself when nobody is pointing. Drawn on a transparent canvas
// behind your content, and paused when off screen.
// Usage: <section data-gravity-well>…</section>
// Optional: data-autoplay (the well wanders by itself when idle),
// data-density (particles per 100 × 100 px, default 8), data-pull
// (strength of gravity, default 1), data-colors="#7c3aed,#c4b5fd,#fff"
// (from slow to fast).
var DEFAULTS = { density: 8, pull: 1, colors: ['#8b5cf6', '#a78bfa', '#c4b5fd', '#f5d0fe', '#ffffff'] };
var css =
'[data-gravity-well]{position:relative;isolation:isolate;overflow:hidden;-webkit-tap-highlight-color:transparent}' +
'.gvw-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}' +
'.gvw-core{position:absolute;left:0;top:0;width:120px;height:120px;margin:-60px 0 0 -60px;border-radius:50%;z-index:-1;pointer-events:none;' +
'background:radial-gradient(circle,#fff 0,rgba(255,255,255,.9) 6%,rgba(216,180,254,.6) 16%,rgba(139,92,246,.22) 34%,rgba(139,92,246,0) 60%)}';
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 = 'gvw-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var core = document.createElement('span');
core.className = 'gvw-core';
core.setAttribute('aria-hidden', 'true');
canvas.after(core);
var density = d.density !== undefined ? +d.density : DEFAULTS.density;
var G = 360 * (d.pull !== undefined ? +d.pull : DEFAULTS.pull);
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : DEFAULTS.colors;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
var R = anime.utils.random;
var w = 0, h = 0, dpr = 1, t = 0;
var parts = [];
var well = { x: 0, y: 0 }, goal = { x: 0, y: 0 };
var pointerUntil = -1, glow = 0;
var SOFT = 22 * 22, HORIZON = 9, MAX_V = 9;
// Drop a particle somewhere around the well, already moving roughly in orbit.
function spawn(p, anywhere) {
var reach = Math.min(Math.max(w, h) * 0.6, 460);
var dist = anywhere ? R(30, reach) : R(reach * 0.5, reach);
var a = Math.random() * Math.PI * 2;
p.x = well.x + Math.cos(a) * dist;
p.y = well.y + Math.sin(a) * dist * 0.8;
var dx = p.x - well.x, dy = p.y - well.y, r = Math.sqrt(dx * dx + dy * dy) || 1;
var v = Math.sqrt(G / Math.sqrt(r * r + SOFT)) * R(0.75, 1.05, 2);
var dir = Math.random() < 0.88 ? 1 : -1; // most swirl the same way
p.vx = (-dy / r) * v * dir;
p.vy = (dx / r) * v * dir;
p.age = 0;
p.px = p.x; p.py = p.y;
return p;
}
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
var ow = w, oh = h;
w = el.clientWidth;
h = el.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
if (!w || !h) return;
if (!ow || !oh) { well.x = goal.x = w / 2; well.y = goal.y = h / 2; }
else {
parts.forEach(function (p) { p.x *= w / ow; p.y *= h / oh; });
well.x *= w / ow; well.y *= h / oh; goal.x *= w / ow; goal.y *= h / oh;
}
var n = Math.round(Math.min(1400, Math.max(120, (w * h) / 10000 * density)));
while (parts.length < n) parts.push(spawn({}, true));
parts.length = n;
}
function wander() {
var k = t * 0.0003;
goal.x = w * (0.5 + Math.sin(k) * 0.3 + Math.sin(k * 2.3 + 1) * 0.06);
goal.y = h * (0.5 + Math.sin(k * 1.7 + 0.6) * 0.24);
}
function advance(dt, mark) {
t += dt;
if (t > pointerUntil) {
if (autoplay) wander();
else { goal.x = w / 2; goal.y = h / 2; }
}
var ease = Math.min(1, dt * 0.0035);
well.x += (goal.x - well.x) * ease;
well.y += (goal.y - well.y) * ease;
glow *= Math.pow(0.996, dt);
var s = dt / 16;
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
p.age += dt;
var dx = well.x - p.x, dy = well.y - p.y;
var r2 = dx * dx + dy * dy;
var r = Math.sqrt(r2) || 1;
if (r < HORIZON) {
glow = Math.min(1, glow + 0.06);
spawn(p, false);
continue;
}
var f = G / Math.pow(r2 + SOFT, 1.5);
if (mark !== false) { p.px = p.x; p.py = p.y; }
p.vx += dx * f * s;
p.vy += dy * f * s;
// A little drag, so orbits slowly decay into the well.
var drag = Math.pow(0.9975, s);
p.vx *= drag; p.vy *= drag;
var v = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
if (v > MAX_V) { p.vx *= MAX_V / v; p.vy *= MAX_V / v; }
p.x += p.vx * s;
p.y += p.vy * s;
if (p.x < -w * 0.6 || p.x > w * 1.6 || p.y < -h * 0.6 || p.y > h * 1.6) spawn(p, false);
}
}
// Each frame fades what's there a little and draws on top, leaving trails.
function draw(fresh) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (fresh) ctx.clearRect(0, 0, w, h);
else {
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0,0,0,.2)';
ctx.fillRect(0, 0, w, h);
}
// Streaks from where each particle was to where it is, batched by speed
// so each colour is one path.
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'butt';
var buckets = colors.length;
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 / 4.2) * buckets));
if (k !== b || p.age < 30) continue;
ctx.moveTo(fresh ? p.x - p.vx * 3 : p.px, fresh ? p.y - p.vy * 3 : p.py);
ctx.lineTo(p.x + 0.01, p.y);
}
ctx.strokeStyle = colors[b];
ctx.globalAlpha = 0.75 + (b / buckets) * 0.25;
ctx.lineWidth = 1.4 + b * 0.15;
ctx.stroke();
}
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
// The well: a bright core and a halo that flares as it swallows particles.
core.style.transform = 'translate(' + well.x.toFixed(1) + 'px,' + well.y.toFixed(1) + 'px) scale(' + (0.8 + glow * 0.5).toFixed(3) + ')';
}
resize();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(true); }).observe(el);
for (var warm = 0; warm < 90; warm++) advance(16);
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
draw(true);
return;
}
function point(e, hold) {
var r = el.getBoundingClientRect();
goal.x = e.clientX - r.left;
goal.y = e.clientY - r.top;
pointerUntil = t + hold;
}
el.addEventListener('pointermove', function (e) { point(e, e.pointerType === 'mouse' ? 1200 : 2500); });
el.addEventListener('pointerdown', function (e) { point(e, 2500); });
el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') pointerUntil = t + 300; });
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;
// Two half steps keep the close orbits steady.
advance(dt / 2, true);
advance(dt / 2, false);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-gravity-well]').forEach(function (el) {
if (el._gvw) return;
el._gvw = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-gravity-well data-autoplay style="background: #0b0616; color: #fff">
<h1>Pulled in</h1>
</section>