← Generative art

Harmonograph

A pendulum drawing machine. Two pendulums swing the pen from side to side and two more swing it up and down, each at a slightly different rate, while friction slowly dies them down. The pen’s loops drift, shrink and weave into a fine spiral. Loop it and it starts again with new rates.

How to use it

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

Options

Add any of these attributes to the same element.

data-colorsThe ink’s colour at the start and at the end of the drawing. Default #7c3aed,#db2777.
data-durationHow long each drawing takes. Default 11000 ms.
data-loopHold the drawing, fade it and draw another with new rates.

The rates sit close to simple ratios like 2 : 3, but a hair off, which is what makes the loops creep round. Victorian parlours had real ones, with a pen on a lever swung by pendulums. It’s drawn on a transparent canvas and paused when off screen. With reduced motion it shows a finished drawing.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Harmonograph: a pendulum drawing machine. Two pendulums swing the pen
    // side to side and two more swing it up and down, each at a slightly
    // different rate, while friction slowly dies them down. The pen traces
    // loops that drift and shrink into a fine, woven spiral. With data-loop it
    // holds the drawing, fades it and sets the pendulums swinging again with
    // new rates. Drawn on a transparent canvas and paused when off screen.
    // Usage: <div data-harmonograph style="height:360px"></div>
    // Optional: data-colors (the ink's colour at the start and end of the
    // drawing, default "#7c3aed,#db2777"), data-duration (how long each
    // drawing takes, ms, default 11000), data-loop (draw another with new
    // rates).
    var DEFAULTS = { colors: '#7c3aed,#db2777', duration: 11000, hold: 2400 };
    // How far the drawing runs: until the swings are about 4% of where they started.
    var FADE_TO = 0.04;

    var css =
      '[data-harmonograph]{position:relative;isolation:isolate;overflow:hidden}' +
      '.hmg-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 rand(a, b) { return a + Math.random() * (b - a); }
    function pick(list) { return list[Math.floor(Math.random() * list.length)]; }

    function rgb(hex) {
      var n = parseInt(hex.trim().replace('#', ''), 16);
      return [n >> 16, n >> 8 & 255, n & 255];
    }

    // Four pendulums: [amplitude, rate, phase, damping] each.
    function pendulums() {
      var pairs = [[2, 3], [3, 2], [3, 4], [2, 2], [3, 3], [1, 3], [3, 1], [4, 3]];
      var f = pick(pairs), g = pick(pairs), damp = rand(0.0035, 0.006), split = rand(0.45, 0.65);
      function one(a, rate) { return [a, rate + rand(-0.015, 0.015), rand(0, Math.PI * 2), damp * rand(0.7, 1.3)]; }
      return [one(split, f[0]), one(1 - split, g[0]), one(split, f[1]), one(1 - split, g[1])];
    }

    function build(el) {
      var d = el.dataset;
      var ink = document.createElement('canvas'), over = document.createElement('canvas');
      ink.className = over.className = 'hmg-canvas';
      ink.setAttribute('aria-hidden', 'true');
      over.setAttribute('aria-hidden', 'true');
      el.insertBefore(over, el.firstChild);
      el.insertBefore(ink, over);
      var ictx = ink.getContext('2d'), octx = over.getContext('2d');
      var cs = (d.colors || DEFAULTS.colors).split(',').map(rgb);
      if (cs.length < 2) cs.push(cs[0]);
      var duration = +d.duration || DEFAULTS.duration;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var w = 0, h = 0, dpr = 1, rx = 1, ry = 1, cx = 0, cy = 0;
      var P = pendulums(), t = 0, end = 1, phase = 'draw', age = 0, fading = 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);
        ink.width = over.width = Math.round(w * dpr);
        ink.height = over.height = Math.round(h * dpr);
        var pad = Math.max(12, Math.min(w, h) * 0.07);
        ry = h / 2 - pad;
        rx = Math.min(w / 2 - pad, ry * 1.35);
        cx = w / 2;
        cy = h / 2;
        return true;
      }

      function start() {
        ictx.setTransform(1, 0, 0, 1, 0, 0);
        ictx.clearRect(0, 0, ink.width, ink.height);
        P = pendulums();
        // Run until the slowest-dying pendulum has nearly stopped.
        var slow = Math.min(P[0][3], P[1][3], P[2][3], P[3][3]);
        end = Math.log(1 / FADE_TO) / slow;
        t = 0;
        phase = 'draw';
        age = 0;
      }

      function swing(p, u) { return p[0] * Math.sin(p[1] * u + p[2]) * Math.exp(-p[3] * u); }
      function pos(u) {
        return [cx + rx * (swing(P[0], u) + swing(P[1], u)), cy + ry * (swing(P[2], u) + swing(P[3], u))];
      }

      // Ink the line from time a to b, in a colour that drifts as it goes.
      function trace(a, b) {
        var k = a / end, c = [0, 1, 2].map(function (i) { return Math.round(cs[0][i] + (cs[1][i] - cs[0][i]) * k); });
        ictx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ictx.strokeStyle = 'rgb(' + c.join(',') + ')';
        ictx.globalAlpha = 0.5;
        ictx.lineWidth = Math.max(0.7, Math.min(1.1, Math.min(w, h) / 360));
        ictx.lineJoin = 'round';
        ictx.beginPath();
        var q = pos(a);
        ictx.moveTo(q[0], q[1]);
        for (var u = a; u < b;) {
          u = Math.min(b, u + 0.02);
          q = pos(u);
          ictx.lineTo(q[0], q[1]);
        }
        ictx.stroke();
        ictx.globalAlpha = 1;
      }

      function drawOver() {
        octx.setTransform(dpr, 0, 0, dpr, 0, 0);
        octx.clearRect(0, 0, w, h);
        if (phase !== 'draw') return;
        var q = pos(t);
        octx.fillStyle = 'rgba(40,20,70,.85)';
        octx.beginPath();
        octx.arc(q[0], q[1], 3, 0, Math.PI * 2);
        octx.fill();
        octx.strokeStyle = 'rgba(40,20,70,.25)';
        octx.lineWidth = 6;
        octx.beginPath();
        octx.arc(q[0], q[1], 7, 0, Math.PI * 2);
        octx.stroke();
      }

      function tick(dt) {
        age += dt;
        if (phase === 'draw') {
          var b = Math.min(end, t + end * dt / duration);
          trace(t, b);
          t = b;
          if (t >= end) { phase = 'hold'; age = 0; }
        } else if (phase === 'hold' && loop && !fading && age > DEFAULTS.hold) {
          fading = true;
          anime.animate(ink, {
            opacity: 0, duration: 800, ease: 'inOut(2)',
            onComplete: function () { start(); fading = false; anime.utils.set(ink, { opacity: 1 }); }
          });
        }
      }

      function still() {
        var keep = P;
        start();
        P = keep;
        for (var u = 0; u < end; u += 20) trace(u, Math.min(end, u + 20));
        phase = 'hold';
        drawOver();
      }

      resize();
      start();
      if (reduced()) {
        still();
        if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) still(); }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () {
        if (!resize()) return;
        // Redraw what's done so far at the new size.
        var upto = t;
        ictx.setTransform(1, 0, 0, 1, 0, 0);
        ictx.clearRect(0, 0, ink.width, ink.height);
        for (var u = 0; u < upto; u += 20) trace(u, Math.min(upto, u + 20));
      }).observe(el);

      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;
          tick(dt);
          drawOver();
        }
      });
      // Pause while off screen to save battery.
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-harmonograph]').forEach(function (el) {
        if (el._hmg) return;
        el._hmg = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-harmonograph data-loop style="height: 360px; background: #fbf7ef"></div>