Circuit Board
A seamlessly tiling PCB backdrop — seeded traces with 45°/90° corners, pads, vias and component footprints, plus an optional signal pulse.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/circuit-board.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CircuitBoard" component — a seamlessly
tiling PCB backdrop: traces routed with 45°/90° corners, terminated in pads and
vias, with a couple of component footprints. Its only dependency is a cn() class
merger (clsx + tailwind-merge). It IS a client component ("use client") for
exactly one reason: React.useId().
Contract
- export function CircuitBoard(props): props extend
Omit<React.ComponentProps<"div">, "children"> (rest props spread onto the root
div) plus:
- seed?: number (default 1) — integer that picks the board. Same seed, same
routing, forever.
- density?: number (default 12, clamped 1-40) — traces routed per tile.
- scale?: number (default 280, clamped 80-1200) — tile size in px; the board
repeats every `scale` px.
- strokeWidth?: number (default 1, clamped 0.25-4) — trace width in px at the
rendered tile size.
- pulse?: boolean (default false) — a light blip travelling along each trace.
- tone?: "border" | "muted" | "primary" (default "muted") — which semantic
token the artwork inherits.
- It renders no children: it is a decoration layer, like a background image.
The consumer puts it inside a `relative isolate` ancestor and writes content
in a `relative` sibling that stacks above it.
- Numeric props are clamped and non-finite values fall back to the default:
scale=0 divides by zero when the stroke width is compensated for the pattern
transform, and density=0 would draw nothing at all.
Behavior
- One tile is a GRID x GRID lattice of nodes (GRID = 20, so the authoring step
is exactly 5 in a 0-100 square and every emitted coordinate is a short
integer — that alone roughly halves the path payload).
- EVERYTHING IS ROUTED ON A TORUS. Node coordinates are taken modulo GRID, so a
trace that leaves the right edge continues from the left edge of the same
tile — which is the neighbouring tile once the pattern repeats. That is the
whole seamlessness trick, and it is why occupancy must be tracked on wrapped
indices, not on raw ones.
- Traces: pick a free node, pick a direction, then walk. Each step moves one
cell in one of 8 compass directions; the next direction is chosen from a
preference list of {keep, +-45deg, +-90deg} and the first still-legal entry
wins, so a blocked straight run bends instead of dying. A step is legal only
if the target node is unused AND, for diagonals, the cell it cuts through has
no diagonal yet — two diagonals in one cell cross at its centre, and a
single-layer board does not cross. Runs are 5-16 steps.
- Determinism is mandatory. The layout comes from a seeded mulberry32 PRNG,
never Math.random() and never Date.now(). Random values during render break
SSR/client parity and are a lint error under react-hooks/purity. The routed
board is memoized on (seed, density).
- Drawing across the seam: keep a second pair of coordinates for drawing that
live in [0, GRID] and stay congruent to the logical node modulo GRID. A step
can only leave that range from a node already sitting on the boundary; when
it does, shift BOTH endpoints one tile back and start a new subpath. The
segment then lands on the opposite edge where the neighbouring tile expects
it, and nothing is lost to the pattern's clip. Use stroke-linecap="round":
the cap overhangs the tile edge, is clipped, and thereby fills the boundary
pixel completely — that is what stops a hairline seam from antialiasing.
- Terminals: each trace end is a filled pad or a hollow via ring. Component
footprints are rectangles placed first, reserving their nodes plus a one-node
clearance ring so traces route around them, with pin stubs on the two long
sides and a pin-1 dot inside.
- Pads, vias and footprints near an edge get an explicit copy on the opposite
side (x +- tile, y +- tile) — unlike a trace, a circle straddling the seam
cannot be expressed by wrapping a node index, so the missing half has to be
painted where the neighbouring tile will show it.
- UNIQUE PATTERN ID IS MANDATORY. SVG ids are document-global, so two instances
sharing one id make the second silently paint the first one's board. Derive
it from React.useId() and strip the characters that are illegal in an XML id
(and therefore inside url(#...) and href="#..."):
`zy-circuit-${React.useId().replace(/[^a-zA-Z0-9]/g, "")}`. This is the only
reason the component needs "use client".
- Trace geometry is emitted as THREE path strings (traces round-robin into
three groups) declared once in <defs> and drawn with <use>. Three groups
exist so the pulse can run at three different phases; declaring them once
means turning the pulse on costs three more elements instead of a second copy
of every path string.
- The board is authored in a 0-100 square and placed inside
<pattern patternUnits="userSpaceOnUse" width={scale} height={scale}> under a
transform="scale(scale/100)", with strokeWidth pre-divided by the same factor
so the trace keeps the requested pixel width. Consequence: changing `scale`
is only a transform, it never re-routes the board.
- pulse=true adds three more <use> elements over the same geometry with
stroke-dasharray "6 164" and a CSS animation walking stroke-dashoffset from
one full dash+gap period down to 0. The period (170 units) is longer than the
longest possible trace, so at most one blip rides each trace; the three
groups get animation-delay 0 / -1/3 / -2/3 of the duration
(calc on --zy-circuit-speed) so they do not fire in lockstep. Do NOT drive
this from requestAnimationFrame: a background must not own a JS frame loop.
- COST, HONESTLY: the dash animation is not compositable, so the browser
repaints the pattern every frame. Measured in Edge on a 1440x900 viewport at
density 26: ~0.27s of main-thread task time per 5s with the pulse on versus
0.000s with it off (the static layer costs literally nothing after first
paint). Keep it off for full-page backdrops and long documents.
- prefers-reduced-motion: the pulse layer is motion-reduce:hidden — removed
entirely, not slowed and not frozen mid-trace, and display:none also stops
the animation from ticking. The static board is untouched.
Rendering & styling
- Semantic tokens only. Everything is stroke/fill "currentColor" and the root
div sets that colour with a token utility plus the alpha that makes both
themes read the same: "border" -> text-border, "muted" ->
text-muted-foreground opacity-35, "primary" -> text-primary opacity-25. No
hex / rgb() / oklch() anywhere, so the board re-skins itself with the host
theme and gets dark mode for free. Both halves are plain utilities, so a
consumer's className can out-merge either one through cn().
- Depth comes from three opacity groups inside the pattern, not from colour:
footprints 0.5 (with a 0.09 fill), traces 0.65, pads and vias 0.9. Pad radius
is derived from the stroke width (2.4x, clamped 1.2-3 authoring units) so the
board stays in proportion at any `scale`.
- Root: aria-hidden="true" pointer-events-none absolute inset-0 overflow-hidden.
It is pure decoration so it must never take a click.
- It belongs under content, not over it. Measured on the default tone: ink lands
at ~1.35:1 against the light background and ~1.37:1 against the dark one —
visible as texture, nowhere near competing with body text.
- The @keyframes ship inside the component via a React 19 hoisted
<style href="zyeon-circuit-board" precedence="medium"> tag — no Tailwind
config edits, and multiple instances dedupe to one style tag by href.
Customization levers
- Layout: `seed` — try a handful of integers and keep the board that suits the
composition. Nothing else about the component changes.
- Trace density: `density` (1-40). 4-6 reads as a sparse illustration, 12 as a
texture, 24+ as a busy motherboard; footprint count follows it
(1 + floor(density / 8), capped at 4).
- Feature size: `scale`, the tile in px. 160-220 for small busy surfaces,
400-600 for full-page hero backdrops where a few big features is the point.
Two ends of the range bite: one lattice cell is scale/20 px, so `strokeWidth`
above roughly a third of that merges every trace into a solid mass (at
scale=80 that ceiling is ~1.3px); and a container much smaller than one tile
can land on an empty stretch of board, so keep `scale` under about the
container's shorter side unless a mostly-empty backdrop is what you want.
- Weight: `strokeWidth` in px, independent of `scale` because it is pre-divided
by the pattern transform. Pads and vias follow it automatically.
- Palette: `tone` maps to a (token, alpha) pair — extend that record with e.g.
accent or destructive instead of hardcoding a colour anywhere.
- Motion: `pulse` on/off, plus --zy-circuit-speed for the duration (default 7s)
and --zy-circuit-cycle if you retune the dash period. Under ~3s the blips
stop reading as signals and start reading as blinking.
- Board character: the walk's turn-preference lists (how often it keeps going
versus takes a 45deg or 90deg corner), the 5-16 step budget, and the
footprint size range 3-5 x 2-3 cells.
- Payload vs. detail: the emitted geometry ships inside the SSR HTML. At the
defaults that is ~1.1 kB of path data and ~4.3 kB of markup for the whole
layer; density 40 grows it to ~2.1 kB / ~9.6 kB. To cut it, lower `density`
first, then drop GRID from 20 to 16 for a coarser lattice (note that 16 makes
the step 6.25, so coordinates gain two decimals and the saving is smaller
than it looks).
- Fade: give it a mask-image (radial or linear) through className if the board
should dissolve toward the edges instead of meeting the container border.Concepts
- Torus routing = no seam — the walk indexes its lattice modulo the tile, so the board is periodic over exactly one tile. A trace crossing the right edge continues at the identical row on the left edge, which is what lets an SVG
<pattern>repeat forever without a visible join. - Boundary subpath break — drawing coordinates are kept separately from logical ones and are only allowed inside
[0, GRID]. A step that would leave that range restarts the subpath one tile back, so the segment is painted where the neighbouring tile will show it instead of being swallowed by the pattern's clip. Round line caps overhang the edge and get clipped, which fills the boundary pixel and kills the antialiasing hairline. - Wrap copies for round things — a trace can cross the seam by wrapping an index, but a pad or a footprint straddling the seam cannot: it is explicitly re-drawn at
x ± tile/y ± tilewhenever it sits within 8 authoring units of an edge. - Seeded PRNG layout — every choice (start node, direction, turn, run length, terminal type, footprint placement) is drawn from a seeded mulberry32, not
Math.random(). That keeps render pure (noreact-hooks/purityviolation), makes SSR and hydration agree, and turns "which board" into a single integer you can commit to source. - Shared geometry via
use— the three trace groups are declared once in<defs>and referenced by<use>, so the pulse layer reuses the exact same path data instead of shipping a second copy of it. Grouping in threes is what lets the blips run at three phases. - Dash-driven signal, not a frame loop — the pulse is a CSS
stroke-dashoffsetanimation over a dash whose gap is longer than any trace, so at most one blip rides each line. It costs a pattern repaint per frame (~0.27s of main-thread work per 5s at 1440×900, density 26) against exactly zero for the static board — which is why it is opt-in and whyprefers-reduced-motionremoves the layer outright. - Per-instance pattern id — SVG ids are document-global; two instances sharing one would silently paint the same board. The id is derived from
useId()with XML-illegal characters stripped, which is the single reason this component is client-side.
Hex Grid
A seamlessly tiling honeycomb backdrop — outlined, tinted or vertex-dotted, with an optional edge fade and a few seeded cells breathing on CSS alone.
Voronoi Cells
A slowly flowing Voronoi diagram on one canvas — organic cell walls, tinted mosaic or separated pebbles, seeded so the same field comes back every time.