← Generative art

Spirograph

A toothed gear rolls round the inside of a ring and a pen through one of its holes traces a looping curve. When the curve meets itself, a new gear and a new ink colour start over the top, and three curves layer up into a pattern, just like the toy.

How to use it

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

Options

Add any of these attributes to the same element.

data-colorsComma-separated ink colours, one per curve. Default violet, pink, teal and orange.
data-curvesHow many curves to layer up. Default 3.
data-speedSpeed multiplier. Default 1.
data-gears="false"Hide the ring and gear, and just draw.
data-loopHold the finished pattern, fade it and start a new one.

The ring has 96 teeth. A gear with 60 teeth shares a factor of 12 with it, so it rolls round the ring 5 times (60 ÷ 12) before the pen gets back to where it started; gears that share smaller factors go round more times and draw more loops. It’s drawn on a transparent canvas and paused when off screen. With reduced motion it shows a finished pattern.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Spirograph: a toothed gear rolls round the inside of a ring, and a pen
    // through one of its holes traces a looping curve. When the curve closes,
    // a new gear and a new colour draw over it, and the curves layer up into
    // a pattern. Drawn on a transparent canvas and paused when off screen.
    // Usage: <div data-spirograph style="height:360px"></div>
    // Optional: data-colors (comma-separated pen colours, default violet,
    // pink, teal and orange), data-curves (how many curves to layer up before
    // starting again, default 3), data-speed (speed multiplier, default 1),
    // data-gears="false" (hide the gears and just draw), data-loop (once the
    // pattern is done, hold it, fade it and start a new one).
    var DEFAULTS = { colors: '#7c3aed,#db2777,#0d9488,#ea580c', curves: 3, speed: 1, hold: 2200 };
    // Ring of 96 teeth; each gear: its teeth, and how far out the pen hole is (0..1).
    var RING = 96;
    var GEARS = [[60, 0.82], [42, 0.86], [52, 0.7], [75, 0.62], [30, 0.95], [63, 0.78], [36, 0.6], [45, 0.9]];

    var css =
      '[data-spirograph]{position:relative;isolation:isolate;overflow:hidden}' +
      '.spg-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 gcd(a, b) { return b ? gcd(b, a % b) : a; }

    function build(el) {
      var d = el.dataset;
      var ink = document.createElement('canvas'), over = document.createElement('canvas');
      ink.className = over.className = 'spg-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 colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      var perPattern = Math.max(1, +d.curves || DEFAULTS.curves);
      var speed = +d.speed || DEFAULTS.speed;
      var showGears = d.gears !== 'false';
      var loop = d.loop !== undefined && d.loop !== 'false';
      var w = 0, h = 0, dpr = 1, unit = 1, cx = 0, cy = 0;
      // The curve being drawn: gear teeth r, pen hole p, how far round (theta) out of end.
      var r = 60, p = 0.8, theta = 0, end = 1, color = colors[0], lap = 0;
      var drawn = 0, deal = Math.floor(Math.random() * GEARS.length), turn = 0;
      var 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);
        cx = w / 2; cy = h / 2;
        // Ring radius in px, divided by its teeth.
        unit = (Math.min(w, h) / 2 - Math.max(10, Math.min(w, h) * 0.06)) / RING;
        return true;
      }

      // Pen and gear centre at angle t, in px.
      function pen(t) {
        var k = (RING - r) / r, R0 = (RING - r) * unit, pd = r * p * unit;
        return [cx + R0 * Math.cos(t) + pd * Math.cos(k * t), cy + R0 * Math.sin(t) - pd * Math.sin(k * t)];
      }

      function nextCurve() {
        var g = GEARS[deal % GEARS.length];
        deal++;
        r = g[0];
        p = g[1];
        lap = r / gcd(RING, r);
        end = Math.PI * 2 * lap;
        // Start each curve somewhere new round the ring.
        turn = Math.random() * Math.PI * 2;
        theta = 0;
        color = colors[drawn % colors.length];
        phase = 'draw';
      }

      function clearInk() {
        ictx.setTransform(1, 0, 0, 1, 0, 0);
        ictx.clearRect(0, 0, ink.width, ink.height);
        drawn = 0;
        nextCurve();
      }

      // Ink the curve from angle a to b.
      function trace(a, b) {
        ictx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ictx.strokeStyle = color;
        ictx.globalAlpha = 0.85;
        ictx.lineWidth = Math.max(1, Math.min(1.6, unit * 0.9));
        ictx.lineCap = ictx.lineJoin = 'round';
        ictx.beginPath();
        var q = pen(a + turn);
        ictx.moveTo(q[0], q[1]);
        for (var t = a; t < b;) {
          t = Math.min(b, t + 0.02);
          q = pen(t + turn);
          ictx.lineTo(q[0], q[1]);
        }
        ictx.stroke();
        ictx.globalAlpha = 1;
      }

      function teeth(x, y, rad, n, rot, inward) {
        octx.beginPath();
        for (var i = 0; i < n; i++) {
          var a = rot + i * Math.PI * 2 / n, c = Math.cos(a), s = Math.sin(a), t = inward ? -3 : 3;
          octx.moveTo(x + c * rad, y + s * rad);
          octx.lineTo(x + c * (rad + t), y + s * (rad + t));
        }
        octx.stroke();
      }

      // The overlay: the ring, the rolling gear and the pen.
      function drawOver() {
        octx.setTransform(dpr, 0, 0, dpr, 0, 0);
        octx.clearRect(0, 0, w, h);
        if (!showGears || phase !== 'draw') return;
        var R = RING * unit, k = (RING - r) / r, a = theta + turn;
        var gx = cx + (RING - r) * unit * Math.cos(a), gy = cy + (RING - r) * unit * Math.sin(a);
        octx.strokeStyle = 'rgba(60,40,90,.28)';
        octx.lineWidth = 1;
        octx.beginPath();
        octx.arc(cx, cy, R, 0, Math.PI * 2);
        octx.stroke();
        if (R > 60) teeth(cx, cy, R, RING, 0, false);
        // The gear, turning backwards as it rolls.
        octx.fillStyle = 'rgba(124,58,237,.08)';
        octx.strokeStyle = 'rgba(60,40,90,.4)';
        octx.beginPath();
        octx.arc(gx, gy, r * unit, 0, Math.PI * 2);
        octx.fill();
        octx.stroke();
        if (R > 60) teeth(gx, gy, r * unit, r, -k * a, true);
        var q = pen(a);
        octx.beginPath();
        octx.moveTo(gx, gy);
        octx.lineTo(q[0], q[1]);
        octx.stroke();
        octx.fillStyle = 'rgba(60,40,90,.45)';
        octx.beginPath();
        octx.arc(gx, gy, 2, 0, Math.PI * 2);
        octx.fill();
        octx.fillStyle = color;
        octx.beginPath();
        octx.arc(q[0], q[1], 3.5, 0, Math.PI * 2);
        octx.fill();
        octx.strokeStyle = '#fff';
        octx.lineWidth = 1.5;
        octx.stroke();
      }

      function tick(dt) {
        age += dt;
        if (phase === 'draw') {
          // Every curve takes 3 to 7 seconds, however many laps it has.
          var ms = Math.min(7000, 2600 + lap * 220) / speed;
          var b = Math.min(end, theta + end * dt / ms);
          trace(theta, b);
          theta = b;
          if (theta >= end) {
            drawn++;
            if (drawn < perPattern) nextCurve();
            else { phase = 'hold'; age = 0; }
          }
        } else if (phase === 'hold' && loop && !fading && age > DEFAULTS.hold) {
          fading = true;
          anime.animate(ink, {
            opacity: 0, duration: 700, ease: 'inOut(2)',
            onComplete: function () { clearInk(); fading = false; anime.utils.set(ink, { opacity: 1 }); }
          });
        }
      }

      // Draw the whole pattern at once (reduced motion).
      function still() {
        clearInk();
        for (var i = 0; i < perPattern; i++) {
          if (i) nextCurve();
          trace(0, end);
          drawn++;
        }
        phase = 'hold';
        drawOver();
      }

      resize();
      clearInk();
      if (reduced()) {
        still();
        if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) still(); }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) clearInk(); }).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-spirograph]').forEach(function (el) {
        if (el._spg) return;
        el._spg = true;
        build(el);
      });
    }

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