← Diagrams & maps

Circuit Traces

A circuit board with a chip in the middle and copper traces fanning out from its pins, bending at right angles and 45°. Glowing signals race along the traces with light trailing behind them, and each via flashes as a signal lands. The board is laid out fresh to fit whatever size it is.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-circuit-traces to an element and give it a height. It works well as a section background.

Options

Add any of these attributes to the same element.

data-colorsSignal colours, separated by commas. Default #e879f9,#22d3ee.
data-boardBoard colour. Default #140c26.
data-traceTrace colour. Default #3a2a63.
data-labelText printed on the chip. Default none.
data-speedHow fast the signals travel, as a multiplier. Default 1.
data-densityHow many signals run at once, as a multiplier. Default 1.
data-gridSpacing between traces. Default 10 px.

It is drawn on a canvas, 300px tall unless you set a height, and pauses when scrolled out of view. Put content on top with position: relative.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Circuit Traces: a circuit board drawn on a canvas, with a chip in the
    // middle and copper traces fanning out from its pins at right angles and
    // 45-degree bends. Glowing signals race along the traces, trailing light,
    // and each via flashes as a signal lands on it. A new board is laid out to
    // fit whatever size it is.
    // Usage:
    //   <div data-circuit-traces style="height: 320px"></div>
    // Optional: data-colors (comma-separated signal colours, default
    // #e879f9,#22d3ee), data-board (background, default #140c26), data-trace
    // (trace colour, default #3a2a63), data-label (text on the chip, default
    // none), data-speed (signal speed multiplier, default 1), data-density
    // (how many signals at once, multiplier, default 1), data-grid (spacing
    // between traces in px, default 10).
    var DEFAULTS = { colors: '#e879f9,#22d3ee', board: '#140c26', trace: '#3a2a63', speed: 1, density: 1, grid: 10 };
    var DIRS = [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]];

    var css =
      ':where(.cct){position:relative;width:100%;height:300px}' +
      '.cct{overflow:hidden}' +
      '.cct-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function rnd(a, b) { return a + Math.random() * (b - a); }

    function setup(el) {
      var d = el.dataset;
      var s = (el._cct = { visible: false, raf: 0, pulses: [], flashes: [], traces: [] });
      s.colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      el.classList.add('cct');
      s.canvas = document.createElement('canvas');
      s.canvas.className = 'cct-canvas';
      s.canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(s.canvas);
      s.ctx = s.canvas.getContext('2d');
      s.board = document.createElement('canvas');
      if (!el.getAttribute('aria-label')) el.setAttribute('aria-label', 'Signals racing along the traces of a circuit board');
      el.setAttribute('role', 'img');
      build(el);
      new ResizeObserver(function () {
        if (Math.abs(el.clientWidth - s.w) > 2 || Math.abs(el.clientHeight - s.h) > 2) {
          build(el);
          if (!s.raf) render(el, 0);
        }
      }).observe(el);
    }

    // Lay out a fresh board: a chip, traces fanning out from its pins, and
    // a scattering of shorter traces elsewhere.
    function build(el) {
      var s = el._cct, d = el.dataset, dpr = Math.min(2, window.devicePixelRatio || 1);
      var w = (s.w = el.clientWidth), h = (s.h = el.clientHeight);
      if (!w || !h) return;
      [s.canvas, s.board].forEach(function (c) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); });
      s.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      var g = Math.max(6, +d.grid || DEFAULTS.grid);
      var cols = Math.floor(w / g), rows = Math.floor(h / g);
      var ox = (w - (cols - 1) * g) / 2, oy = (h - (rows - 1) * g) / 2;
      var occ = new Uint8Array(cols * rows);
      function free(c, r) { return c >= 1 && r >= 1 && c < cols - 1 && r < rows - 1 && !occ[r * cols + c]; }
      function mark(c, r) { occ[r * cols + c] = 1; }
      var traces = (s.traces = []);
      s.pulses = [];
      s.flashes = [];

      function walk(c, r, dir, maxLen, fromChip) {
        var base = dir, cells = [[c, r]];
        mark(c, r);
        for (var n = 0; n < maxLen; n++) {
          var options = [dir];
          if (Math.random() < 0.16) {
            var turn = Math.random() < 0.5 ? 1 : 7;
            var nd = (dir + turn) % 8, diff = Math.min((nd - base + 8) % 8, (base - nd + 8) % 8);
            if (diff <= 1) options.unshift(nd);
          }
          if (dir !== base) options.push(base);
          var moved = false;
          for (var i = 0; i < options.length && !moved; i++) {
            var v = DIRS[options[i]], nc = c + v[0], nr = r + v[1];
            if (!free(nc, nr)) continue;
            // Don't cut across another diagonal.
            if (v[0] && v[1] && occ[r * cols + nc] && occ[nr * cols + c]) continue;
            c = nc; r = nr; dir = options[i];
            mark(c, r);
            cells.push([c, r]);
            moved = true;
          }
          if (!moved) break;
        }
        if (cells.length < (fromChip ? 3 : 4)) return;
        // Keep only the corners.
        var pts = [cells[0]];
        for (var k = 1; k < cells.length - 1; k++) {
          var a = cells[k - 1], b = cells[k], e = cells[k + 1];
          if (b[0] - a[0] !== e[0] - b[0] || b[1] - a[1] !== e[1] - b[1]) pts.push(b);
        }
        pts.push(cells[cells.length - 1]);
        var t = { pts: pts.map(function (p) { return [ox + p[0] * g, oy + p[1] * g]; }), fromChip: fromChip, cum: [0] };
        for (var m = 1; m < t.pts.length; m++) {
          var dx = t.pts[m][0] - t.pts[m - 1][0], dy = t.pts[m][1] - t.pts[m - 1][1];
          t.cum.push(t.cum[m - 1] + Math.sqrt(dx * dx + dy * dy));
        }
        t.len = t.cum[t.cum.length - 1];
        traces.push(t);
      }

      // The chip, centred, with a clear margin round it.
      var side = Math.max(4, Math.min(16, Math.round(Math.min(cols * 0.3, rows * 0.42))));
      var c0 = Math.round(cols / 2 - side / 2), r0 = Math.round(rows / 2 - side / 2);
      for (var r = r0 - 1; r <= r0 + side; r++) for (var c = c0 - 1; c <= c0 + side; c++) if (r >= 0 && c >= 0 && r < rows && c < cols) mark(c, r);
      s.chip = { x: ox + (c0 - 0.5) * g, y: oy + (r0 - 0.5) * g, size: side * g };
      var starts = [];
      for (var i = 1; i < side - 1; i += 2) {
        starts.push([c0 + i, r0 - 2, 6], [c0 + i, r0 + side + 1, 2], [c0 - 2, r0 + i, 4], [c0 + side + 1, r0 + i, 0]);
      }
      var pins = (s.pins = []);
      starts.sort(function () { return Math.random() - 0.5; }).forEach(function (st) {
        if (!free(st[0], st[1])) return;
        var before = traces.length;
        walk(st[0], st[1], st[2], Math.round(rnd(6, cols + rows)), true);
        if (traces.length > before) pins.push(st);
      });
      var extra = Math.round((cols * rows) / 55);
      for (var e = 0; e < extra * 3 && extra > 0; e++) {
        var sc = Math.floor(rnd(2, cols - 2)), sr = Math.floor(rnd(2, rows - 2));
        if (!free(sc, sr) || !free(sc + 1, sr) || !free(sc - 1, sr) || !free(sc, sr + 1) || !free(sc, sr - 1)) continue;
        var before2 = traces.length;
        walk(sc, sr, Math.floor(Math.random() * 4) * 2, Math.round(rnd(5, 26)), false);
        if (traces.length > before2) extra--;
      }
      s.grid = g;
      drawBoard(el);
    }

    function drawBoard(el) {
      var s = el._cct, d = el.dataset, ctx = s.board.getContext('2d'), dpr = s.board.width / s.w;
      var board = d.board || DEFAULTS.board, trace = d.trace || DEFAULTS.trace, g = s.grid;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.fillStyle = board;
      ctx.fillRect(0, 0, s.w, s.h);
      ctx.lineJoin = 'round';
      ctx.lineCap = 'round';
      ctx.strokeStyle = trace;
      ctx.lineWidth = Math.max(1.6, g * 0.22);
      s.traces.forEach(function (t) {
        ctx.beginPath();
        t.pts.forEach(function (p, i) { if (i) ctx.lineTo(p[0], p[1]); else ctx.moveTo(p[0], p[1]); });
        ctx.stroke();
      });
      // Vias: a ring with a hole at the open ends.
      function via(p) {
        ctx.beginPath();
        ctx.arc(p[0], p[1], g * 0.38, 0, Math.PI * 2);
        ctx.fillStyle = trace;
        ctx.fill();
        ctx.beginPath();
        ctx.arc(p[0], p[1], g * 0.16, 0, Math.PI * 2);
        ctx.fillStyle = board;
        ctx.fill();
      }
      s.traces.forEach(function (t) {
        via(t.pts[t.pts.length - 1]);
        if (!t.fromChip) via(t.pts[0]);
      });
      // The chip and its pins.
      var c = s.chip;
      ctx.lineCap = 'butt';
      ctx.lineWidth = g * 0.45;
      s.traces.forEach(function (t) {
        if (!t.fromChip) return;
        // A pin from the chip's edge to where the trace starts.
        var p = t.pts[0], q = t.pts[1], dx = Math.sign(q[0] - p[0]), dy = Math.sign(q[1] - p[1]);
        ctx.beginPath();
        ctx.moveTo(p[0], p[1]);
        ctx.lineTo(p[0] - dx * g * 1.6, p[1] - dy * g * 1.6);
        ctx.stroke();
      });
      var rad = Math.min(8, c.size * 0.08);
      ctx.beginPath();
      if (ctx.roundRect) ctx.roundRect(c.x, c.y, c.size, c.size, rad);
      else ctx.rect(c.x, c.y, c.size, c.size);
      ctx.fillStyle = '#0b0618';
      ctx.fill();
      ctx.lineWidth = 1.5;
      ctx.strokeStyle = shade(trace, 0.25);
      ctx.stroke();
      ctx.beginPath();
      ctx.arc(c.x + c.size * 0.16, c.y + c.size * 0.16, Math.max(2, c.size * 0.045), 0, Math.PI * 2);
      ctx.fillStyle = shade(trace, 0.15);
      ctx.fill();
      if (d.label) {
        ctx.fillStyle = shade(trace, 0.55);
        ctx.font = '700 ' + Math.max(9, Math.min(16, c.size * 0.16)) + 'px ui-monospace, Menlo, monospace';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(d.label, c.x + c.size / 2, c.y + c.size / 2);
      }
    }

    function shade(hex, f) {
      var n = parseInt(hex.slice(1), 16), r = n >> 16, g = (n >> 8) & 255, b = n & 255;
      function c(v) { return Math.round(v + (255 - v) * f); }
      return 'rgb(' + c(r) + ',' + c(g) + ',' + c(b) + ')';
    }

    function pointAt(t, dist) {
      dist = Math.max(0, Math.min(t.len, dist));
      for (var i = 1; i < t.cum.length; i++) {
        if (dist <= t.cum[i]) {
          var a = t.pts[i - 1], b = t.pts[i], f = (dist - t.cum[i - 1]) / ((t.cum[i] - t.cum[i - 1]) || 1);
          return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f];
        }
      }
      return t.pts[t.pts.length - 1];
    }

    function spawn(el, mid) {
      var s = el._cct, d = el.dataset;
      if (!s.traces.length) return;
      var t = s.traces[Math.floor(Math.random() * s.traces.length)];
      var speed = (+d.speed || DEFAULTS.speed) * rnd(90, 190);
      // Signals mostly leave the chip; on loose traces they go either way.
      var back = t.fromChip ? Math.random() < 0.25 : Math.random() < 0.5;
      s.pulses.push({
        t: t, back: back, pos: mid ? rnd(0.2, 0.8) * t.len : 0, speed: speed,
        tail: rnd(30, 70), color: s.colors[Math.floor(Math.random() * s.colors.length)],
      });
    }

    function render(el, dt) {
      var s = el._cct, ctx = s.ctx;
      if (!s.w) return;
      ctx.globalCompositeOperation = 'source-over';
      ctx.drawImage(s.board, 0, 0, s.w, s.h);
      ctx.globalCompositeOperation = 'lighter';
      ctx.lineCap = 'round';
      var lw = Math.max(1.6, s.grid * 0.22);
      s.pulses = s.pulses.filter(function (p) {
        p.pos += p.speed * dt;
        var t = p.t, head = Math.min(p.pos, t.len);
        if (p.pos - p.tail > t.len) return false;
        // The trail: short pieces, fading towards the back.
        var steps = 8;
        for (var i = 0; i < steps; i++) {
          var d0 = head - (p.tail * (i + 1)) / steps, d1 = head - (p.tail * i) / steps;
          if (d1 <= 0) break;
          var a = pointAt(t, p.back ? t.len - Math.max(0, d0) : Math.max(0, d0));
          var b = pointAt(t, p.back ? t.len - d1 : d1);
          ctx.beginPath();
          ctx.moveTo(a[0], a[1]);
          ctx.lineTo(b[0], b[1]);
          ctx.strokeStyle = p.color;
          ctx.globalAlpha = (1 - i / steps) * 0.85;
          ctx.lineWidth = lw * (1.4 - (i / steps) * 0.6);
          ctx.stroke();
        }
        ctx.globalAlpha = 1;
        if (p.pos < t.len) {
          var hp = pointAt(t, p.back ? t.len - head : head);
          var gr = ctx.createRadialGradient(hp[0], hp[1], 0, hp[0], hp[1], lw * 4.5);
          gr.addColorStop(0, '#ffffff');
          gr.addColorStop(0.25, p.color);
          gr.addColorStop(1, 'rgba(0,0,0,0)');
          ctx.fillStyle = gr;
          ctx.beginPath();
          ctx.arc(hp[0], hp[1], lw * 4.5, 0, Math.PI * 2);
          ctx.fill();
        } else if (!p.landed) {
          p.landed = true;
          var end = p.back ? t.pts[0] : t.pts[t.pts.length - 1];
          if (!(p.back && t.fromChip)) s.flashes.push({ x: end[0], y: end[1], age: 0, color: p.color });
        }
        return true;
      });
      s.flashes = s.flashes.filter(function (f) {
        f.age += dt;
        var k = f.age / 0.6;
        if (k >= 1) return false;
        var r = s.grid * (0.5 + k * 1.6);
        var gr = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
        gr.addColorStop(0, f.color);
        gr.addColorStop(1, 'rgba(0,0,0,0)');
        ctx.globalAlpha = 1 - k;
        ctx.fillStyle = gr;
        ctx.beginPath();
        ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
        ctx.fill();
        ctx.globalAlpha = 1;
        return true;
      });
      ctx.globalCompositeOperation = 'source-over';
    }

    function frame(el, now) {
      var s = el._cct, d = el.dataset;
      s.raf = 0;
      if (!s.visible) return;
      var dt = Math.min(0.05, (now - (s.last || now)) / 1000);
      s.last = now;
      var target = Math.max(3, Math.round(s.traces.length * 0.35 * (+d.density || DEFAULTS.density)));
      if (s.pulses.length < target && Math.random() < dt * target * 1.4) spawn(el);
      render(el, dt);
      s.raf = requestAnimationFrame(function (t) { frame(el, t); });
    }

    function init() {
      var still = reduced();
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target, s = el._cct;
          s.visible = e.isIntersecting;
          if (!s.visible) { cancelAnimationFrame(s.raf); s.raf = 0; return; }
          s.last = 0;
          if (!s.started) {
            s.started = true;
            for (var i = 0; i < Math.max(3, s.traces.length * 0.15); i++) spawn(el, true);
          }
          if (!s.raf) s.raf = requestAnimationFrame(function (t) { frame(el, t); });
        });
      }, { threshold: 0.1 });
      document.querySelectorAll('[data-circuit-traces]').forEach(function (el) {
        if (el._cct) return;
        setup(el);
        var s = el._cct;
        if (still) {
          for (var i = 0; i < Math.max(4, s.traces.length * 0.25); i++) spawn(el, true);
          render(el, 0);
          return;
        }
        render(el, 0);
        io.observe(el);
      });
    }

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