[ Parable build guide ]
How Patchbay was built
A synth maker whose homepage is a synthesizer. The signature is a draggable SVG patch-cable panel — sagging beziers, a plug-in bounce, LEDs that light per module, and an optional Web Audio voice when the classic patch completes.
The idea
Modular synthesis has one honest sales pitch: you wire the instrument, and no two
patches are alike. A screenshot of a rack can't prove that. A rack you can actually patch can —
so the centre of the page is a five-module eurorack row where dragging a cable from
SAW OUT to AUDIO IN does exactly what it does on the hardware.
Complete VCO → VCF → VCA → out and the rack sings. Patch the LFO
somewhere weird and it sings weirder. The interaction is the brand argument.
The stack
Vite with vanilla TypeScript — no framework. The panel is a data model
(MODULES, each with jacks, knobs and HP widths) rendered to SVG at runtime; the
audio is one small Web Audio graph that mirrors the patch state. Space Grotesk carries the
display voice (technical but warm), Inter does body text, and JetBrains Mono handles
everything a faceplate would print: HP widths, current draw, jack labels. Both themes live as
token sets on :root[data-theme] — rack-black by night, panel-silver by day —
persisted under the patchbay-theme key.
Signature technique — cables that sag
A patch cable is not a line; it's a line that has given up in the middle. Every cable is a
quadratic bezier whose control point hangs below the lower of its two endpoints — sag grows
with distance, plus a slack term the animation loop plays with:
/* the cable curve: endpoints + a low midpoint. gravity, roughly. */
function cablePath(ax: number, ay: number, bx: number, by: number, slack = 0): string {
const dist = Math.hypot(bx - ax, by - ay);
const sag = Math.min(95, 24 + dist * 0.22) + slack;
const mx = (ax + bx) / 2;
const my = Math.max(ay, by) + sag;
return `M ${ax} ${ay} Q ${mx} ${my} ${bx} ${by}`;
}
/* on plug-in: a damped bounce, then a slow idle sway */
let slack = Math.sin(t / 950 + c.phase) * 2.2;
if (age < 900) slack += 30 * Math.exp(-age / 200) * Math.cos(age / 65);
The bounce is a damped cosine — the cable overshoots and settles like it was actually let go — and the idle sway gives each cable its own phase so a full rack never moves in lockstep. Completion detection is a five-line BFS over the patch graph (with the filter and VCA acting as pass-throughs), so any route from oscillator to output counts, including the rude one that skips the filter entirely.
Details that matter
- Sound is opt-in. The
AudioContextis created inside the button's click handler — autoplay policy satisfied by design — and the graph idles at zero gain until a signal path exists. The page defaults to silence. - Keyboard patching is first-class. Every jack is a focusable
role="button"with a livearia-labelthat names what it's patched to. Enter picks up a cable, Enter plugs it in, Esc drops it — and the preset buttons do the whole wiring for anyone who'd rather not. - Reduced motion is honest. Cables draw instantly with zero sway, the scope renders exactly one frame, LEDs hold steady, and reveals resolve on the spot. No loop runs at all.
- The loops are cheap. Scope and cable animation both throttle to ~30 fps,
cap devicePixelRatio at 1.5, and pause via
IntersectionObserverthe moment they scroll out of view. - Content is never hidden without JS. Reveal and hero states hide behind a
.jsclass set synchronously in<head>; kill JavaScript and the whole page reads, with a<noscript>note where the panel would be. - The shop talks back. Every patch writes a line to an
aria-livestatus — "LFO on the cutoff — the filter is breathing" — so the panel narrates itself to screen readers and to everyone else.
The build-slot form is a demo — it validates and confirms in place but sends nothing. Wire it to your own endpoint (Formspree, a serverless function, a mailing-list API) before taking real orders.
Ship it on GitHub Pages
Vite builds into docs/ with base: '/patchbay/', so Pages can serve
the repo's docs folder with zero extra config. The guide you're reading lives in
public/guide/ and is copied through untouched.
npm run build # tsc --noEmit && vite build → docs/
gh repo create bswxyz/patchbay --public --source . --push
# repo → Settings → Pages → deploy from branch → main /docs
A .nojekyll file in public/ keeps Pages from post-processing the
output, and every asset URL resolves under the /patchbay/ base.