← Generative art

Dragon Curve

Start with a single line. Copy it, and swing the copy a quarter turn round the line’s end, like folding a strip of paper in half and opening it out at right angles. Do it again and again, and a dragon folds out of the line, its coils touching but never crossing.

How to use it

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

Options

Add any of these attributes to the same element.

data-depthNumber of folds, 4 to 15. Default fits the box.
data-foldTime per fold. Default 900 ms.
data-colorsThree colours along the curve. Default #f472b6,#a78bfa,#38bdf8.
data-loopHold the finished dragon, fade it and fold again.

The view glides to keep the whole curve in frame as it doubles. It’s drawn on a transparent canvas and paused when off screen. With reduced motion it shows the finished dragon.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Dragon Curve: start with a single line. Copy it, and swing the copy a
    // quarter turn round the line's end, like folding a strip of paper in
    // half and opening it out at right angles. Do it again and again, and a
    // dragon folds out of the line, its coils never crossing. The view keeps
    // the whole curve in frame as it grows. Drawn on a transparent canvas and
    // paused when off screen.
    // Usage: <div data-dragon-curve style="height:360px"></div>
    // Optional: data-depth (number of folds, default fits the box, up to 15),
    // data-fold (time per fold in ms, default 900), data-colors (three
    // colours along the curve, default "#f472b6,#a78bfa,#38bdf8"), data-loop
    // (hold the finished dragon, fade it and fold again).
    var DEFAULTS = { fold: 900, colors: '#f472b6,#a78bfa,#38bdf8', pause: 260, hold: 3200 };
    var BINS = 60;

    var css =
      '[data-dragon-curve]{position:relative;isolation:isolate;overflow:hidden}' +
      '.drc-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 rgb(hex) {
      var n = parseInt(hex.trim().replace('#', ''), 16);
      return [n >> 16, n >> 8 & 255, n & 255];
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'drc-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var foldMs = +d.fold || DEFAULTS.fold;
      var cs = (d.colors || DEFAULTS.colors).split(',').map(rgb);
      while (cs.length < 3) cs.push(cs[cs.length - 1]);
      var loop = d.loop !== undefined && d.loop !== 'false';
      var colors = [];
      for (var b = 0; b < BINS; b++) {
        var k = b / (BINS - 1), c0 = k < 0.5 ? cs[0] : cs[1], c1 = k < 0.5 ? cs[1] : cs[2], f = k < 0.5 ? k * 2 : k * 2 - 1;
        colors.push('rgb(' + Math.round(c0[0] + (c1[0] - c0[0]) * f) + ',' + Math.round(c0[1] + (c1[1] - c0[1]) * f) + ',' + Math.round(c0[2] + (c1[2] - c0[2]) * f) + ')');
      }
      var w = 0, h = 0, dpr = 1, depth = 12;
      // pts: the curve so far, as x, y pairs; n points. Room for the deepest fold.
      var pts = new Float64Array(2 * (Math.pow(2, 15) + 1)), n = 2;
      var view = null, folds = 0, age = 0, phase = 'fold', fading = false;

      function reset() {
        pts[0] = 0; pts[1] = 0; pts[2] = 1; pts[3] = 0;
        n = 2;
        folds = 0;
        age = 0;
        phase = 'pause';
        view = null;
      }

      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);
        // Stop folding once a step would be shorter than about 2.5 px.
        depth = +d.depth || Math.floor(2 * Math.log(Math.min(w, h) * 0.6 / 2.5) / Math.LN2);
        depth = Math.max(4, Math.min(15, depth));
        return true;
      }

      // Commit a finished fold: append the copy, swung a quarter turn.
      function commit() {
        var px = pts[(n - 1) * 2], py = pts[(n - 1) * 2 + 1];
        for (var i = n - 2; i >= 0; i--) {
          var dx = pts[i * 2] - px, dy = pts[i * 2 + 1] - py;
          pts[n * 2] = px - dy;
          pts[n * 2 + 1] = py + dx;
          n++;
        }
        folds++;
      }

      function draw(a) {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        var px = pts[(n - 1) * 2], py = pts[(n - 1) * 2 + 1], c = Math.cos(a), s = Math.sin(a);
        var total = a ? n * 2 - 1 : n;
        // Point i of the whole curve, including the swinging copy.
        function at(i, out) {
          if (i < n) { out[0] = pts[i * 2]; out[1] = pts[i * 2 + 1]; return; }
          var j = 2 * n - 2 - i, dx = pts[j * 2] - px, dy = pts[j * 2 + 1] - py;
          out[0] = px + dx * c - dy * s;
          out[1] = py + dx * s + dy * c;
        }
        var p = [0, 0], minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
        for (var i = 0; i < total; i++) {
          at(i, p);
          if (p[0] < minX) minX = p[0];
          if (p[0] > maxX) maxX = p[0];
          if (p[1] < minY) minY = p[1];
          if (p[1] > maxY) maxY = p[1];
        }
        // Fit the box, easing towards the new fit so it glides.
        var pad = Math.max(14, Math.min(w, h) * 0.08);
        var target = { s: Math.min((w - pad * 2) / Math.max(1e-6, maxX - minX), (h - pad * 2) / Math.max(1e-6, maxY - minY)), x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
        if (!view) view = target;
        else {
          view.s += (target.s - view.s) * 0.12;
          view.x += (target.x - view.x) * 0.12;
          view.y += (target.y - view.y) * 0.12;
        }
        var sc = view.s, ox = w / 2 - view.x * sc, oy = h / 2 - view.y * sc;
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        ctx.lineWidth = Math.max(1, Math.min(4, sc * 0.4));
        var per = Math.max(1, Math.ceil((total - 1) / BINS));
        for (var bin = 0; bin * per < total - 1; bin++) {
          var from = bin * per, to = Math.min(total - 1, from + per);
          ctx.beginPath();
          at(from, p);
          ctx.moveTo(ox + p[0] * sc, oy + p[1] * sc);
          for (i = from + 1; i <= to; i++) {
            at(i, p);
            ctx.lineTo(ox + p[0] * sc, oy + p[1] * sc);
          }
          ctx.strokeStyle = colors[Math.min(BINS - 1, Math.floor(from / (total - 1) * BINS))];
          ctx.stroke();
        }
      }

      function angle() {
        if (phase !== 'fold') return 0;
        var k = Math.min(1, age / foldMs);
        return (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2) * Math.PI / 2;
      }

      function tick(dt) {
        age += dt;
        if (phase === 'pause' && age > DEFAULTS.pause) { phase = 'fold'; age = 0; }
        else if (phase === 'fold' && age >= foldMs) {
          commit();
          age = 0;
          phase = folds >= depth ? 'hold' : 'pause';
        } else if (phase === 'hold' && loop && age > DEFAULTS.hold && !fading) {
          fading = true;
          anime.animate(canvas, {
            opacity: 0, duration: 600, ease: 'inOut(2)',
            onComplete: function () { reset(); fading = false; anime.utils.set(canvas, { opacity: 1 }); }
          });
        }
      }

      resize();
      reset();
      if (reduced()) {
        while (folds < depth) commit();
        draw(0);
        if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) { reset(); while (folds < depth) commit(); draw(0); } }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) reset(); }).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(angle());
        }
      });
      // 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-dragon-curve]').forEach(function (el) {
        if (el._drc) return;
        el._drc = true;
        build(el);
      });
    }

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