← Back to Crux

[ 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.

vite + typescriptinline SVG — no imagesArchivo Black / Inter / Martian Monolight + darkreduced-motion aware

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 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.

← Back to Crux