Backgrounds

Circuit Board

A seamlessly tiling PCB backdrop — seeded traces with 45°/90° corners, pads, vias and component footprints, plus an optional signal pulse.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * Keyframes ship inside the component via React 19 hoisted <style> —
 * no tailwind config edits, and duplicates dedupe by href.
 * One cycle walks the dash forward by exactly one dash+gap period, so the end
 * frame is identical to the start frame and the signal never snaps back.
 */
const KEYFRAMES = `@keyframes zy-circuit-pulse{from{stroke-dashoffset:var(--zy-circuit-cycle)}to{stroke-dashoffset:0}}`

/** Lattice nodes per tile axis. 20 makes STEP an exact 5, which keeps every

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/circuit-board.json

Prompt

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 ± tile whenever 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 (no react-hooks/purity violation), 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-dashoffset animation 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 why prefers-reduced-motion removes 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.

On This Page