← Generative art

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

  1. Paste the snippet just before </head>.
  2. Add data-double-pendulum to a box with a height and a dark background.

Options

Add any of these attributes to the same element.

data-countHow many pendulums, each started a tiny angle from the last. Default 1.
data-trailLength of the trail, in frames. Default 240; 0 for none.
data-speedTime scale. Default 1; 0.5 is slow motion.
data-ratioLower arm’s length as a share of the upper’s, 0.3 to 2. Default 1.
data-colorsComma-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.

1 · Snippet
<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>
2 · Your HTML
<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>