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
- Paste the snippet just before
</head>. - Add
data-circuit-tracesto 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-colors | Signal colours, separated by commas. Default #e879f9,#22d3ee. |
data-board | Board colour. Default #140c26. |
data-trace | Trace colour. Default #3a2a63. |
data-label | Text printed on the chip. Default none. |
data-speed | How fast the signals travel, as a multiplier. Default 1. |
data-density | How many signals run at once, as a multiplier. Default 1. |
data-grid | Spacing 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.
<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><div data-circuit-traces data-label="A1" style="height: 360px"></div>