[ Parable build guide ]
How Crux was built
A bouldering gym whose hero is the wall itself: an inline-SVG spread of coloured holds where picking a problem traces its line, mat to top-out — each hold latching on in climbing order while everyone else's holds fall back into the plywood.
The idea
Every climbing gym website shows you a photo of a wall. None of them show you what a problem is — the thing climbers actually talk about: a specific line of specific holds, with a grade, a setter and an opinion. So the hero here is a wall you can read. Five of this week's problems live on it, V0 to V7. Tap one and the wall answers the way a friend pointing from the mat would: these holds, in this order, that one's the finish.
The stack
Vite with vanilla TypeScript — no framework. The wall is hand-placed SVG in
index.html: hold positions are markup, not data, so the page is complete and
fully coloured even before JavaScript arrives. TypeScript reads the DOM to learn the routes
(data-route, data-step, data-x/y on each hold) and
carries only the beta — names, grades, setters. Type is Archivo Black for the shouty
tape-label headings, Inter for body, Martian Mono for grades, times and everything a setter
would write on masking tape.
Signature technique — the trace
Selecting a problem does three things: builds one <path> through the
hold centres, animates it with the classic dash-offset trick, and staggers a
latch animation across the holds with a per-hold CSS custom property. The
line and the holds resolve together because both run off the same 130 ms step.
// src/wall.ts — the line, drawn mat to top-out
const d = seq.map((h, i) => `${i === 0 ? 'M' : 'L'}${h.x} ${h.y}`).join(' ');
this.path.setAttribute('d', d);
this.path.style.stroke = `var(--r-${id})`;
const len = this.path.getTotalLength();
this.path.style.strokeDasharray = String(len);
this.path.style.strokeDashoffset = String(len);
void this.path.getBoundingClientRect(); // restart cleanly
this.path.style.transition = `stroke-dashoffset ${seq.length * STEP_MS}ms linear`;
requestAnimationFrame(() => { this.path.style.strokeDashoffset = '0'; });
seq.forEach((h, i) => {
h.el.style.setProperty('--d', `${i * STEP_MS}ms`); // stagger the latch
h.el.classList.add('is-on');
});
The latch itself is pure CSS — an overshoot keyframe on a custom cubic-bezier we named
the dyno (cubic-bezier(.26, 1.45, .42, 1)): scale to 1.25,
settle to 1, delayed per hold by var(--d). Dimming is one selector:
.wall.is-tracing .hold:not(.is-on) drops to 13% opacity, which is what makes
the traced line pop without touching a single other element.
Details that matter
- The wall works without JS. Hold positions are static SVG markup, all holds rendered in full colour — no script, no blank hero. JS only adds the tracing.
- Reduced motion is honest. Under
prefers-reduced-motion, selecting a problem still highlights its holds and draws its full line — one static frame, no sequential animation, no dash transition. - Keyboard first. The picker is real buttons with
aria-pressed, plus arrow-key roving; the beta readout is anaria-liveregion so screen readers hear the route change. - Start and finish read like tape. The first and last hold of a traced problem get a dashed ink ring — the same convention real setters use on real walls.
- One source of truth. Hold coordinates live only in the SVG; the TypeScript never duplicates a position. Route colours are CSS custom properties, so both themes and the picker dots share them for free.
The join form is a demo — it validates and confirms in place but sends nothing. Wire it to your own endpoint (Formspree, a serverless function, your CRM) before taking real sign-ups.
Ship it on GitHub Pages
The Vite config sets base: '/crux/' and builds into docs/, which
is one of the two folders GitHub Pages can serve straight from a branch.
npm run build # tsc --noEmit && vite build → docs/
git add -A && git commit -m "build"
gh repo create bswxyz/crux --public --source . --push
gh api --method POST /repos/bswxyz/crux/pages \
-f 'source[branch]=main' -f 'source[path]=/docs'
An empty public/.nojekyll is copied into docs/ so Pages serves
the Vite output as-is, and every asset URL resolves under the /crux/ base.