N-body Orbits
A few bodies pull on each other by gravity and trace looping, glowing trails. It takes turns between the famous figure-eight, where three equal suns chase each other round one shared track, and little solar systems whose planets tug on each other and on their star, so their orbits swing round into rosettes. Tap or click for a new system.
Tap for a new system
How to use it
- Paste the snippet just before
</head>. - Add
data-n-body-orbitsto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-preset | cycle (take turns), figure8 or system. Default cycle. |
data-bodies | Planets in a system. Default 4. |
data-interval | How long each turn lasts in cycle mode. Default 16000 ms. |
data-trail | Trail length, in recorded points. Default 220. |
data-colors | Comma-separated body colours. Default gold, pink, violet, cyan, orange and green. |
data-speed | Speed multiplier. Default 1. |
Every body pulls on every other one, worked out a few hundred times a second with a leapfrog integrator, which keeps orbits from slowly drifting. If a planet is flung out of the box, a new system starts. It’s paused when off screen, and with reduced motion it shows the traced orbits.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// N-body Orbits: a handful of bodies pull on each other by gravity and
// trace looping, glowing trails. It takes turns between the famous
// three-body figure-eight, where three equal suns chase each other round
// one track, and little solar systems whose planets tug each other into
// rosettes. Tap or click to start a new system.
// Usage: <div data-n-body-orbits style="height:360px;background:#0b0618"></div>
// Optional: data-preset ("cycle", "figure8" or "system", default cycle),
// data-bodies (planets in a system, default 4), data-trail (trail length
// in steps, default 220), data-colors (comma-separated body colours,
// default purples, pinks and gold), data-speed (speed multiplier,
// default 1), data-interval (ms each system lasts in cycle mode,
// default 16000).
var DEFAULTS = { preset: 'cycle', bodies: 4, trail: 220, speed: 1, interval: 16000,
colors: '#fcd34d,#f472b6,#a78bfa,#67e8f9,#fb923c,#86efac' };
var css =
'[data-n-body-orbits]{position:relative;isolation:isolate;overflow:hidden}' +
'.nbd-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgba(hex, a) {
var c = hex.replace('#', '');
if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
var n = parseInt(c, 16);
return 'rgba(' + (n >> 16 & 255) + ',' + (n >> 8 & 255) + ',' + (n & 255) + ',' + a + ')';
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'nbd-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var preset = d.preset || DEFAULTS.preset;
var planets = +d.bodies || DEFAULTS.bodies;
var trailLen = +d.trail || DEFAULTS.trail;
var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
var speed = +d.speed || DEFAULTS.speed;
var interval = +d.interval || DEFAULTS.interval;
var w = 0, h = 0, dpr = 1;
var bodies = [], kind = 'figure8', scale = 1, simRate = 1, age = 0, fade = 0, round = 0;
var EPS2 = 0.0004, DT = 0.0025; // softening (set per system) and time step
function body(x, y, vx, vy, m, ci) {
return { x: x, y: y, vx: vx, vy: vy, ax: 0, ay: 0, m: m, c: colors[ci % colors.length], trail: [] };
}
function figure8() {
kind = 'figure8';
EPS2 = 0.0004;
var vx = -0.93240737, vy = -0.86473146;
var rot = Math.random() * Math.PI * 2;
bodies = [
body(0.97000436, -0.24308753, -vx / 2, -vy / 2, 1, 0),
body(-0.97000436, 0.24308753, -vx / 2, -vy / 2, 1, 1),
body(0, 0, vx, vy, 1, 2)
];
// Lay it flat across a wide box, or tilt it a little.
rot = w >= h ? (Math.random() - 0.5) * 0.3 : Math.PI / 2 + (Math.random() - 0.5) * 0.3;
rotate(rot);
}
function system() {
kind = 'system';
// A softened star: oval orbits then swing round a little each lap,
// tracing rosettes.
EPS2 = 0.03;
var M = 10;
bodies = [body(0, 0, 0, 0, M, 0)];
var r = 0.32;
for (var i = 0; i < planets; i++) {
var a = Math.random() * Math.PI * 2;
var v = Math.sqrt(M / r) * (0.7 + Math.random() * 0.22);
bodies.push(body(Math.cos(a) * r, Math.sin(a) * r, -Math.sin(a) * v, Math.cos(a) * v,
0.05 + Math.random() * 0.25, i + 1));
r += (1 - 0.32) / Math.max(1, planets - 1) * (0.85 + Math.random() * 0.25);
}
// Keep the whole system still overall, so it doesn't drift away.
var px = 0, py = 0, mt = 0;
bodies.forEach(function (b) { px += b.m * b.vx; py += b.m * b.vy; mt += b.m; });
bodies.forEach(function (b) { b.vx -= px / mt; b.vy -= py / mt; });
}
function rotate(a) {
var c = Math.cos(a), s = Math.sin(a);
bodies.forEach(function (b) {
var x = b.x, y = b.y;
b.x = x * c - y * s; b.y = x * s + y * c;
x = b.vx; y = b.vy;
b.vx = x * c - y * s; b.vy = x * s + y * c;
});
}
function fit() {
if (kind === 'figure8') {
var long = Math.max(w, h), short = Math.min(w, h);
scale = Math.min(long / 2.5, short / 0.85);
simRate = 1.2;
} else {
scale = Math.min(w, h) * 0.44;
simRate = 0.3;
}
}
function start() {
var k = preset === 'figure8' ? 'figure8' : preset === 'system' ? 'system' : (round % 2 ? 'system' : 'figure8');
round++;
if (k === 'figure8') figure8(); else system();
fit();
accel();
age = 0;
}
function accel() {
for (var i = 0; i < bodies.length; i++) { bodies[i].ax = 0; bodies[i].ay = 0; }
for (i = 0; i < bodies.length; i++) {
var a = bodies[i];
for (var j = i + 1; j < bodies.length; j++) {
var b = bodies[j];
var dx = b.x - a.x, dy = b.y - a.y;
var r2 = dx * dx + dy * dy + EPS2, inv = 1 / (r2 * Math.sqrt(r2));
a.ax += dx * inv * b.m; a.ay += dy * inv * b.m;
b.ax -= dx * inv * a.m; b.ay -= dy * inv * a.m;
}
}
}
// Leapfrog steps keep the orbits from slowly spiralling in or out.
function step() {
var i, b;
for (i = 0; i < bodies.length; i++) {
b = bodies[i];
b.vx += b.ax * DT / 2; b.vy += b.ay * DT / 2;
b.x += b.vx * DT; b.y += b.vy * DT;
}
accel();
for (i = 0; i < bodies.length; i++) {
b = bodies[i];
b.vx += b.ax * DT / 2; b.vy += b.ay * DT / 2;
}
}
function record() {
for (var i = 0; i < bodies.length; i++) {
var t = bodies[i].trail;
t.push(bodies[i].x, bodies[i].y);
if (t.length > trailLen * 2) t.splice(0, 2);
}
}
function draw(alpha) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.globalAlpha = alpha;
ctx.globalCompositeOperation = 'lighter';
var cx = w / 2, cy = h / 2;
ctx.lineCap = 'butt';
ctx.lineJoin = 'round';
for (var i = 0; i < bodies.length; i++) {
var b = bodies[i], t = b.trail, n = t.length / 2;
if (n < 2) continue;
// The trail in a few pieces, fainter towards its tail.
var pieces = 8, per = Math.ceil(n / pieces);
for (var p = 0; p < pieces; p++) {
var s = p * per, e = Math.min(n - 1, s + per);
if (e <= s) continue;
ctx.strokeStyle = rgba(b.c, 0.06 + 0.6 * (p + 1) / pieces);
ctx.lineWidth = kind === 'system' && i === 0 ? 1 : 1.2 + 1.3 * (p + 1) / pieces;
ctx.beginPath();
ctx.moveTo(cx + t[s * 2] * scale, cy + t[s * 2 + 1] * scale);
for (var q = s + 1; q <= e; q++) ctx.lineTo(cx + t[q * 2] * scale, cy + t[q * 2 + 1] * scale);
ctx.stroke();
}
}
for (i = 0; i < bodies.length; i++) {
b = bodies[i];
var x = cx + b.x * scale, y = cy + b.y * scale;
var rad = kind === 'system' ? (i === 0 ? 9 : 3 + b.m * 8) : 6;
if (Math.min(w, h) < 260) rad *= 0.8;
var g = ctx.createRadialGradient(x, y, 0, x, y, rad * 4);
g.addColorStop(0, rgba(b.c, 0.55));
g.addColorStop(1, rgba(b.c, 0));
ctx.fillStyle = g;
ctx.fillRect(x - rad * 4, y - rad * 4, rad * 8, rad * 8);
ctx.fillStyle = b.c;
ctx.beginPath();
ctx.arc(x, y, rad, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,.85)';
ctx.beginPath();
ctx.arc(x, y, rad * 0.45, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
// Has anything been flung off the edge?
function escaped() {
var lim = Math.max(w, h) / scale * 0.75;
for (var i = 0; i < bodies.length; i++) if (Math.abs(bodies[i].x) > lim || Math.abs(bodies[i].y) > lim) return true;
return false;
}
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
if (nw === w && nh === h) return false;
w = nw; h = nh;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
fit();
return true;
}
resize();
start();
if (reduced()) {
var steps = kind === 'figure8' ? 2600 : 3000;
for (var i = 0; i < steps; i++) { step(); if (i % (kind === 'figure8' ? 8 : 4) === 0) record(); }
draw(1);
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(1); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(1); }).observe(el);
el.addEventListener('pointerdown', function () { if (fade === 0) fade = 0.0001; });
var last = 0, acc = 0, rec = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
age += dt;
acc += dt / 1000 * simRate * speed;
var n = 0;
while (acc >= DT && n < 400) {
step();
acc -= DT; n++;
if (++rec % (kind === 'figure8' ? 8 : 4) === 0) record();
}
if (fade === 0 && preset === 'cycle' && age > interval) fade = 0.0001;
if (fade === 0 && escaped()) fade = 0.0001;
var alpha = 1;
if (fade > 0) {
fade += dt;
alpha = Math.max(0, 1 - fade / 700);
if (fade > 700) { fade = 0; start(); }
} else if (age < 600) alpha = age / 600;
draw(alpha);
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-n-body-orbits]').forEach(function (el) {
if (el._nbd) return;
el._nbd = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-n-body-orbits style="height: 360px; background: #0b0618"></div>
<!-- only the figure-eight: -->
<div data-n-body-orbits data-preset="figure8" style="height: 360px; background: #0b0618"></div>