← Generative art

Lissajous Curves

One oscillation swings a pen from side to side, another swings it up and down at a different rate, and together they trace a Lissajous figure. The timing between them drifts, so the figure seems to turn in 3D, and every few seconds the rates change and it morphs into a new one.

How to use it

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

Options

Add any of these attributes to the same element.

data-ratiosComma-separated side : up rates to take turns with. Default 3:2,5:4,3:4,1:2,5:6,2:3.
data-changeTime on each figure. Default 5000 ms.
data-colorsTwo colours across the figure. Default #f472b6,#a78bfa.
data-speedSpeed multiplier. Default 1.
data-guides="false"Hide the two oscillation dots on the edges.

The dot along the bottom shows the side-to-side swing and the dot up the left shows the up-and-down one. Jules Antoine Lissajous made these figures in 1857 with mirrors on two tuning forks. It’s drawn on a transparent canvas and paused when off screen. With reduced motion it shows a still figure.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Lissajous Curves: one oscillation swings a pen side to side, another
    // swings it up and down at a different rate, and the pen traces a
    // Lissajous figure. The phase between them drifts, so the figure seems to
    // turn in 3D, and every few seconds the rates change and it morphs into a
    // new figure. Two little dots on the edges show the two oscillations.
    // Drawn on a transparent canvas and paused when off screen.
    // Usage: <div data-lissajous-curves style="height:360px"></div>
    // Optional: data-ratios (comma-separated side:up rates to take turns
    // with, default "3:2,5:4,3:4,1:2,5:6,2:3"), data-colors (two colours
    // across the figure, default "#f472b6,#a78bfa"), data-speed (speed
    // multiplier, default 1), data-change (time on each figure, ms, default
    // 5000), data-guides="false" (hide the oscillation dots).
    var DEFAULTS = { ratios: '3:2,5:4,3:4,1:2,5:6,2:3', colors: '#f472b6,#a78bfa', speed: 1, change: 5000, morph: 1400 };
    var STEPS = 900;

    var css =
      '[data-lissajous-curves]{position:relative;isolation:isolate;overflow:hidden}' +
      '.lsj-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 ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'lsj-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var ratios = (d.ratios || DEFAULTS.ratios).split(',').map(function (s) {
        var q = s.split(':');
        return [+q[0] || 1, +q[1] || 1];
      });
      var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      if (colors.length < 2) colors.push(colors[0]);
      var speed = +d.speed || DEFAULTS.speed;
      var change = +d.change || DEFAULTS.change;
      var guides = d.guides !== 'false';
      var w = 0, h = 0, dpr = 1, cx = 0, cy = 0, ax = 1, ay = 1, gx = 0, gy = 0, grad = null;
      // Rates now (fa, fb), the figure we're on, the phase and the pen's position.
      var cur = 0, fa = ratios[0][0], fb = ratios[0][1], from = null, mt = 1;
      var delta = Math.PI / 4, tau = 0, age = 0;

      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);
        // Leave room on the left and at the bottom for the oscillation dots.
        var g = guides ? Math.max(14, Math.min(26, Math.min(w, h) * 0.08)) : 0;
        var pad = Math.max(12, Math.min(w, h) * 0.07);
        var bw = w - g - pad * 2, bh = h - g - pad * 2;
        ay = bh / 2;
        ax = Math.min(bw / 2, ay * 1.5);
        cx = g + pad + bw / 2;
        cy = pad + bh / 2;
        // The guide tracks sit just outside the figure.
        gx = cx - ax - (g ? g / 2 + 6 : 0);
        gy = cy + ay + (g ? g / 2 + 6 : 0);
        grad = ctx.createLinearGradient(cx - ax, cy - ay, cx + ax, cy + ay);
        grad.addColorStop(0, colors[0]);
        grad.addColorStop(1, colors[1]);
        return true;
      }

      function px(t) { return cx + ax * Math.sin(fa * t + delta); }
      function py(t) { return cy - ay * Math.sin(fb * t); }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.lineCap = ctx.lineJoin = 'round';
        // The whole figure, faint.
        ctx.strokeStyle = grad;
        ctx.globalAlpha = 0.38;
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        for (var i = 0; i <= STEPS; i++) {
          var t = i / STEPS * Math.PI * 2;
          if (i) ctx.lineTo(px(t), py(t)); else ctx.moveTo(px(t), py(t));
        }
        ctx.stroke();
        // A bright tail behind the pen.
        ctx.lineCap = 'butt';
        var tail = 5 / Math.max(fa, fb), parts = 40;
        for (var k = 0; k < parts; k++) {
          var t0 = tau - tail * (1 - k / parts), t1 = tau - tail * (1 - (k + 1) / parts), f = (k + 1) / parts;
          ctx.globalAlpha = f * f;
          ctx.lineWidth = 1.5 + f * 1.8;
          ctx.beginPath();
          ctx.moveTo(px(t0), py(t0));
          for (var s = 1; s <= 4; s++) {
            var tt = t0 + (t1 - t0) * s / 4;
            ctx.lineTo(px(tt), py(tt));
          }
          ctx.stroke();
        }
        var x = px(tau), y = py(tau);
        if (guides) {
          // The two oscillations, and dashed lines from them to the pen.
          ctx.globalAlpha = 0.35;
          ctx.strokeStyle = '#fff';
          ctx.lineWidth = 1;
          ctx.setLineDash([2, 4]);
          ctx.beginPath();
          ctx.moveTo(x, gy); ctx.lineTo(x, y);
          ctx.moveTo(gx, y); ctx.lineTo(x, y);
          ctx.stroke();
          ctx.setLineDash([]);
          ctx.globalAlpha = 0.18;
          ctx.lineWidth = 2;
          ctx.beginPath();
          ctx.moveTo(cx - ax, gy); ctx.lineTo(cx + ax, gy);
          ctx.moveTo(gx, cy - ay); ctx.lineTo(gx, cy + ay);
          ctx.stroke();
          ctx.globalAlpha = 1;
          ctx.fillStyle = colors[0];
          ctx.beginPath();
          ctx.arc(x, gy, 4, 0, Math.PI * 2);
          ctx.fill();
          ctx.fillStyle = colors[1];
          ctx.beginPath();
          ctx.arc(gx, y, 4, 0, Math.PI * 2);
          ctx.fill();
        }
        // The pen.
        ctx.globalAlpha = 1;
        var g = ctx.createRadialGradient(x, y, 0, x, y, 16);
        g.addColorStop(0, 'rgba(255,255,255,.9)');
        g.addColorStop(1, 'rgba(255,255,255,0)');
        ctx.fillStyle = g;
        ctx.fillRect(x - 16, y - 16, 32, 32);
        ctx.fillStyle = '#fff';
        ctx.beginPath();
        ctx.arc(x, y, 2.6, 0, Math.PI * 2);
        ctx.fill();
      }

      function tick(dt) {
        age += dt;
        // The pen goes round once every few seconds; the phase drifts slowly.
        tau = (tau + dt / 1000 * 0.9 * speed / Math.max(1, Math.max(fa, fb) / 3)) % (Math.PI * 2 * 1000);
        delta += dt / 1000 * 0.35 * speed;
        if (ratios.length > 1 && age > change) {
          age = 0;
          from = [fa, fb];
          cur = (cur + 1) % ratios.length;
          mt = 0;
        }
        if (mt < 1 && from) {
          mt = Math.min(1, mt + dt / DEFAULTS.morph);
          var e = ease(mt), to = ratios[cur];
          fa = from[0] + (to[0] - from[0]) * e;
          fb = from[1] + (to[1] - from[1]) * e;
        }
      }

      resize();
      if (reduced()) {
        tau = 1;
        draw();
        if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).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);
          draw();
        }
      });
      // 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-lissajous-curves]').forEach(function (el) {
        if (el._lsj) return;
        el._lsj = true;
        build(el);
      });
    }

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