← Generative art

Hilbert Curve

A single line snakes through a square, visiting every cell of a grid once without crossing itself. It starts as a simple U. Then every corner splits into four smaller turns, again and again, until the line reaches into every part of the square, and a bright pulse runs its whole length.

How to use it

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

Options

Add any of these attributes to the same element.

data-orderHow many times to split, 1 to 7. Default fits the box.
data-stepTime per split. Default 1000 ms.
data-colorsTwo colours along the line. Default #6d28d9,#ec4899.
data-pulseColour of the running pulse, or none. Default #f59e0b.
data-loopFold back down to the U and grow again.

Points that are close along the line are always close in the square, which is why Hilbert curves are used to lay out maps and databases. It’s drawn on a transparent canvas and paused when off screen. With reduced motion it shows the finished curve.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Hilbert Curve: a single line snakes through a square, visiting every
    // cell of a grid once without ever crossing itself. It starts as a simple
    // U, then each corner splits into four smaller turns, again and again,
    // until the line reaches into every part of the square. A bright pulse
    // then runs the whole length of it. With data-loop it folds back down to
    // the U and starts again. Drawn on a transparent canvas and paused when
    // off screen.
    // Usage: <div data-hilbert-curve style="height:360px"></div>
    // Optional: data-order (how many times to split, 1 to 7, default fits
    // the box), data-step (time per split in ms, default 1000), data-colors
    // (two colours along the line, default "#6d28d9,#ec4899"), data-pulse
    // (colour of the running pulse, default #f59e0b, or "none"), data-loop
    // (fold back down and grow again).
    var DEFAULTS = { step: 1000, colors: '#6d28d9,#ec4899', pulse: '#f59e0b', pause: 450, run: 3200 };
    var BINS = 48;

    var css =
      '[data-hilbert-curve]{position:relative;isolation:isolate;overflow:hidden}' +
      '.hlb-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];
    }

    // Cell (x, y) of the d-th step along a Hilbert curve through an n by n grid.
    function d2xy(n, d) {
      var x = 0, y = 0, t = d;
      for (var s = 1; s < n; s *= 2) {
        var rx = 1 & (t >> 1), ry = 1 & (t ^ rx);
        if (!ry) {
          if (rx) { x = s - 1 - x; y = s - 1 - y; }
          var tmp = x; x = y; y = tmp;
        }
        x += s * rx;
        y += s * ry;
        t >>= 2;
      }
      return [x, y];
    }

    // Every step's centre for one order, scaled to a unit square.
    function curve(order) {
      var n = 1 << order, out = new Float32Array(n * n * 2);
      for (var i = 0; i < n * n; i++) {
        var p = d2xy(n, i);
        out[i * 2] = (p[0] + 0.5) / n;
        out[i * 2 + 1] = 1 - (p[1] + 0.5) / n;
      }
      return out;
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'hlb-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var stepMs = +d.step || DEFAULTS.step;
      var cs = (d.colors || DEFAULTS.colors).split(',').map(rgb);
      if (cs.length < 2) cs.push(cs[0]);
      var pulse = (d.pulse || DEFAULTS.pulse) === 'none' ? null : d.pulse || DEFAULTS.pulse;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var colors = [];
      for (var b = 0; b < BINS; b++) {
        var k = b / (BINS - 1);
        colors.push('rgb(' + Math.round(cs[0][0] + (cs[1][0] - cs[0][0]) * k) + ',' + Math.round(cs[0][1] + (cs[1][1] - cs[0][1]) * k) + ',' + Math.round(cs[0][2] + (cs[1][2] - cs[0][2]) * k) + ')');
      }
      var curves = [null, curve(1)];
      var w = 0, h = 0, dpr = 1, maxOrder = 5, side = 0, ox = 0, oy = 0;
      // order: the curve on show; morph: 0..1 towards order + 1 (or back).
      var order = 1, morph = 0, dir = 1, phase = 'trace', age = 0;

      function get(o) {
        if (!curves[o]) curves[o] = curve(o);
        return curves[o];
      }

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return;
        w = nw; h = nh;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        var pad = Math.max(14, Math.min(w, h) * 0.08);
        side = Math.min(w, h) - pad * 2;
        ox = (w - side) / 2;
        oy = (h - side) / 2;
        // Split until the cells are about 4.5 px.
        maxOrder = +d.order || Math.floor(Math.log(side / 4.5) / Math.LN2);
        maxOrder = Math.max(1, Math.min(7, maxOrder));
        if (order > maxOrder) { order = maxOrder; morph = 0; }
      }

      function ease(k) { return k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2; }

      // `upto` (0..1) draws only the first part of the line.
      function draw(upto) {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        var from = get(order), to = morph > 0 ? get(order + 1) : null;
        var pts = to || from, count = pts.length / 2, e = ease(morph);
        var cell = side / (1 << order) * (to ? Math.pow(0.5, e) : 1);
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        ctx.lineWidth = Math.max(1.2, Math.min(7, cell * 0.32));
        // Point i, sliding out from its parent's centre while morphing.
        var X = new Float32Array(count), Y = new Float32Array(count);
        for (var i = 0; i < count; i++) {
          var x = pts[i * 2], y = pts[i * 2 + 1];
          if (to) {
            var j = i >> 2;
            x = from[j * 2] + (x - from[j * 2]) * e;
            y = from[j * 2 + 1] + (y - from[j * 2 + 1]) * e;
          }
          X[i] = ox + x * side;
          Y[i] = oy + y * side;
        }
        var end = (count - 1) * (upto === undefined ? 1 : upto);
        var per = Math.max(1, Math.ceil((count - 1) / BINS));
        for (var s = 0; s < end; s += per) {
          var stop = Math.min(end, s + per);
          ctx.beginPath();
          ctx.moveTo(X[s], Y[s]);
          for (i = s + 1; i <= Math.floor(stop); i++) ctx.lineTo(X[i], Y[i]);
          if (stop % 1) {
            var f = stop % 1, a = Math.floor(stop);
            ctx.lineTo(X[a] + (X[a + 1] - X[a]) * f, Y[a] + (Y[a + 1] - Y[a]) * f);
          }
          ctx.strokeStyle = colors[Math.min(BINS - 1, Math.floor(s / (count - 1) * BINS))];
          ctx.stroke();
        }
        // The pulse: a short bright stretch running along the line.
        if (phase === 'run' && pulse) {
          var head = (count - 1) * Math.min(1, age / DEFAULTS.run), tail = Math.max(0, head - Math.max(3, count * 0.04));
          ctx.beginPath();
          ctx.moveTo(X[Math.floor(tail)], Y[Math.floor(tail)]);
          for (i = Math.ceil(tail); i <= Math.floor(head); i++) ctx.lineTo(X[i], Y[i]);
          ctx.strokeStyle = pulse;
          ctx.shadowColor = pulse;
          ctx.shadowBlur = 10;
          ctx.lineWidth *= 1.6;
          ctx.stroke();
          ctx.shadowBlur = 0;
        }
      }

      function tick(dt) {
        age += dt;
        if (phase === 'trace') {
          if (age >= 900) { phase = 'pause'; age = 0; }
        } else if (phase === 'pause') {
          if (age < DEFAULTS.pause) return;
          age = 0;
          if (dir > 0 && order >= maxOrder) { phase = pulse ? 'run' : 'hold'; return; }
          if (dir < 0 && order <= 1) { dir = 1; return; }
          phase = 'morph';
          // Going down: start from the finer curve and fold it in.
          if (dir < 0) { order--; morph = 1; }
        } else if (phase === 'morph') {
          var k = Math.min(1, age / stepMs);
          morph = dir > 0 ? k : 1 - k;
          if (k >= 1) {
            if (dir > 0) order++;
            morph = 0;
            phase = 'pause';
            age = 0;
          }
        } else if (phase === 'run') {
          if (age >= DEFAULTS.run + 600) {
            age = 0;
            if (loop) { dir = -1; phase = 'pause'; } else phase = 'run';
          }
        } else if (phase === 'hold' && loop && age > 2500) {
          dir = -1; phase = 'pause'; age = 0;
        }
      }

      resize();
      if (reduced()) {
        order = maxOrder;
        phase = 'hold';
        draw();
        if (window.ResizeObserver) new ResizeObserver(function () { resize(); order = maxOrder; draw(); }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(resize).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(phase === 'trace' ? ease(Math.min(1, age / 900)) : undefined);
        }
      });
      // 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-hilbert-curve]').forEach(function (el) {
        if (el._hlb) return;
        el._hlb = true;
        build(el);
      });
    }

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