Double Pendulum
A pendulum hung from the end of another pendulum swings, flips and whirls chaotically, never quite repeating, and its lower bob leaves a fading trail. On this page three are let go a tiny fraction of a degree apart: they move as one for a few seconds, then go their separate ways. Tap or click to let them go again from a new spot.
Tap to let go again
How to use it
- Paste the snippet just before
</head>. - Add
data-double-pendulumto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-count | How many pendulums, each started a tiny angle from the last. Default 1. |
data-trail | Length of the trail, in frames. Default 240; 0 for none. |
data-speed | Time scale. Default 1; 0.5 is slow motion. |
data-ratio | Lower arm’s length as a share of the upper’s, 0.3 to 2. Default 1. |
data-colors | Comma-separated colours, one per pendulum. Default #f472b6,#818cf8,#facc15,#34d399. |
The motion comes from the real equations for two equal weights on light rods, solved with a fourth-order Runge–Kutta step 480 times a second, so energy holds steady and it runs for ever. It’s paused when off screen, and with reduced motion it shows a still with the path it took.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Double Pendulum: a pendulum hung from the end of another pendulum. It
// swings chaotically, never repeating, and its lower bob leaves a fading
// trail. Add more pendulums started a hair's breadth apart and watch them
// agree for a few seconds, then go their separate ways.
// Tap or click to let them go again from a new spot.
// Usage: <div data-double-pendulum style="height:360px;background:#120a24"></div>
// Optional: data-count (how many pendulums, started a tiny angle apart,
// default 1), data-trail (trail length in frames, default 240), data-speed
// (time scale, default 1), data-colors (comma-separated colours, one per
// pendulum, default pink, indigo, amber, green), data-ratio (lower arm's
// length as a share of the upper's, default 1).
var DEFAULTS = { count: 1, trail: 240, speed: 1, ratio: 1, colors: '#f472b6,#818cf8,#facc15,#34d399' };
var G = 9.81, STEP = 1 / 480;
var css =
'[data-double-pendulum]{position:relative;isolation:isolate;overflow:hidden;touch-action:manipulation}' +
'.dpd-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 build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'dpd-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var count = Math.max(1, Math.min(8, Math.round(+d.count || DEFAULTS.count)));
var trailLen = Math.max(0, +d.trail || DEFAULTS.trail);
if (d.trail === '0') trailLen = 0;
var speed = +d.speed || DEFAULTS.speed;
var ratio = Math.max(0.3, Math.min(2, +d.ratio || DEFAULTS.ratio));
var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
// Arm lengths in metres (they set the timing); drawn scaled to fit the box.
var l1 = 1, l2 = ratio, m1 = 1, m2 = 1;
var w = 0, h = 0, dpr = 1, unit = 1, pends = [];
// Angular accelerations of the two arms, from the equations of motion.
function accel(t1, t2, w1, w2) {
var dlt = t1 - t2, den = 2 * m1 + m2 - m2 * Math.cos(2 * dlt);
var a1 = (-G * (2 * m1 + m2) * Math.sin(t1) - m2 * G * Math.sin(t1 - 2 * t2) -
2 * Math.sin(dlt) * m2 * (w2 * w2 * l2 + w1 * w1 * l1 * Math.cos(dlt))) / (l1 * den);
var a2 = (2 * Math.sin(dlt) * (w1 * w1 * l1 * (m1 + m2) + G * (m1 + m2) * Math.cos(t1) +
w2 * w2 * l2 * m2 * Math.cos(dlt))) / (l2 * den);
return [a1, a2];
}
// One fourth-order Runge-Kutta step: steady enough to run for ever.
function rk4(p, dt) {
var s = [p.t1, p.t2, p.w1, p.w2];
function f(v) { var a = accel(v[0], v[1], v[2], v[3]); return [v[2], v[3], a[0], a[1]]; }
function add(v, k, m) { return [v[0] + k[0] * m, v[1] + k[1] * m, v[2] + k[2] * m, v[3] + k[3] * m]; }
var k1 = f(s), k2 = f(add(s, k1, dt / 2)), k3 = f(add(s, k2, dt / 2)), k4 = f(add(s, k3, dt));
p.t1 += dt / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]);
p.t2 += dt / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]);
p.w1 += dt / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2]);
p.w2 += dt / 6 * (k1[3] + 2 * k2[3] + 2 * k3[3] + k4[3]);
}
function launch() {
// High up and off to one side, where the motion is wildest.
var t1 = (Math.random() < 0.5 ? -1 : 1) * (Math.PI * (0.55 + Math.random() * 0.35));
var t2 = t1 + (Math.random() - 0.5) * Math.PI * 1.2;
pends = [];
for (var i = 0; i < count; i++) {
pends.push({ t1: t1 + i * 0.0015, t2: t2, w1: 0, w2: 0, trail: [], color: colors[i % colors.length] });
}
}
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);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Both arms laid end to end must reach no further than the box's edge.
unit = Math.min(w, h) * 0.45 / (l1 + l2);
pends.forEach(function (p) { p.trail = []; });
return true;
}
function joints(p) {
var ox = w / 2, oy = h / 2;
var x1 = ox + Math.sin(p.t1) * l1 * unit, y1 = oy + Math.cos(p.t1) * l1 * unit;
return [ox, oy, x1, y1, x1 + Math.sin(p.t2) * l2 * unit, y1 + Math.cos(p.t2) * l2 * unit];
}
function simulate(seconds) {
var n = Math.round(seconds / STEP);
for (var i = 0; i < n; i++) pends.forEach(function (p) { rk4(p, STEP); });
pends.forEach(function (p) {
var j = joints(p);
p.trail.push(j[4], j[5]);
if (p.trail.length > trailLen * 2) p.trail.splice(0, p.trail.length - trailLen * 2);
});
}
function draw() {
ctx.clearRect(0, 0, w, h);
ctx.lineCap = ctx.lineJoin = 'round';
// Trails: older stretches fainter and thinner, drawn in short runs.
pends.forEach(function (p) {
var tr = p.trail, n = tr.length / 2, RUN = 6;
ctx.strokeStyle = p.color;
for (var s = 0; s < n - 1; s += RUN) {
var f = (s + RUN) / n;
ctx.globalAlpha = Math.min(1, f) * 0.85;
ctx.lineWidth = 0.6 + f * 2;
ctx.beginPath();
ctx.moveTo(tr[s * 2], tr[s * 2 + 1]);
for (var k = s + 1; k <= Math.min(n - 1, s + RUN); k++) ctx.lineTo(tr[k * 2], tr[k * 2 + 1]);
ctx.stroke();
}
});
ctx.globalAlpha = 1;
var r = Math.max(4, unit * 0.11);
pends.forEach(function (p, i) {
var j = joints(p);
ctx.globalAlpha = i === 0 ? 1 : 0.7;
ctx.strokeStyle = 'rgba(255,255,255,.75)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(j[0], j[1]);
ctx.lineTo(j[2], j[3]);
ctx.lineTo(j[4], j[5]);
ctx.stroke();
ctx.fillStyle = '#e9e2ff';
ctx.beginPath();
ctx.arc(j[2], j[3], r * 0.8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = p.color;
ctx.shadowColor = p.color;
ctx.shadowBlur = 14;
ctx.beginPath();
ctx.arc(j[4], j[5], r, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
});
ctx.globalAlpha = 1;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(w / 2, h / 2, 3, 0, Math.PI * 2);
ctx.fill();
}
launch();
resize();
if (reduced()) {
// A still: the arms where they are a few seconds in, with the path they took.
for (var q = 0; q < trailLen; q++) simulate(1 / 60);
draw();
if (window.ResizeObserver) new ResizeObserver(function () {
if (resize()) { for (var q2 = 0; q2 < trailLen; q2++) simulate(1 / 60); draw(); }
}).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
el.addEventListener('pointerdown', function () { launch(); });
var last = 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;
simulate(dt / 1000 * speed);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-double-pendulum]').forEach(function (el) {
if (el._dpd) return;
el._dpd = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-double-pendulum style="height: 360px; background: #120a24"></div>
<!-- three, a hair apart: -->
<div data-double-pendulum data-count="3" style="height: 360px; background: #120a24"></div>