[ Parable build guide ]
How Splits was built
A city marathon site with two working signatures: a course elevation profile that draws itself as you scroll — runner dot riding the line, km markers popping in as it passes them — and a pace calculator that turns a goal time into every split on the course. Vite + vanilla TypeScript, no framework, no chart library.
The idea
Every race site shows an elevation chart as a static JPEG that runners squint at and screenshot. But a marathon is experienced in order — the false flat, the climb, the summit, the sting at 34 — so the chart should be too. Scrolling the page replays the course: the line draws from gun to tape, and each named point surfaces as you "reach" it. The pace calculator closes the loop: once you set a goal, the course readout tells you the clock time you'll crest Reservoir Hill. The chart and the math talk to each other, which is the moment the site stops being a brochure.
The stack
Vite with vanilla TypeScript — no framework, because the whole thing is two interactive islands
and a pile of honest HTML. The elevation SVG is inline in the markup, fully drawn:
kill JavaScript and you still get the complete chart, the schedule, and every word of copy. The
modules (course.ts, pace.ts, countdown.ts, reveal.ts)
only add behavior. Type is Anton — a bib-number typeface if there ever was one — with Inter for
body and Martian Mono for anything a race official would print.
Signature technique — the self-drawing course
The profile path ships in the HTML with real coordinates (x = 40 + km / 42.195 × 940,
y = 280 − ele × 2). At runtime we measure the path, dash it to exactly its own length,
and feed the dash offset from scroll progress. The runner dot is the same trick inverted:
getPointAtLength converts progress back into a coordinate on the line — and its
x-position tells us which km markers the "runner" has passed.
// course.ts — the draw, verbatim
const lineLen = line.getTotalLength();
line.style.strokeDasharray = `${lineLen}`;
line.style.strokeDashoffset = `${lineLen}`;
const update = (): void => {
const r = fig.getBoundingClientRect();
const vh = window.innerHeight;
const p = clamp((vh * 0.9 - r.top) / (vh * 0.75), 0, 1);
line.style.strokeDashoffset = `${lineLen * (1 - p)}`;
const pt = line.getPointAtLength(lineLen * p);
runner.setAttribute('transform', `translate(${pt.x.toFixed(1)} ${pt.y.toFixed(1)})`);
groups.forEach((g, m) => {
if (pt.x >= m.x - 1) g.classList.add('is-past'); // marker pops in
});
};
addEventListener('scroll', onScroll, { passive: true }); // rAF-throttled
The markers are real SVG <g role="button" tabindex="0"> elements — hover,
click, or Tab to one and the readout panel updates with the elevation, the grade, and what the
locals call that stretch. The pace calculator broadcasts a splits:plan event when it
recalculates; the readout listens and appends your projected arrival time at that marker.
Details that matter
- No JS, no loss. The SVG is complete in the markup. The dash trick only ever
hides what JavaScript is about to reveal, and it's gated behind a
.jsclass set synchronously in<head>. - Reduced motion is a render mode, not an apology. Under
prefers-reduced-motionthe line ships fully drawn, every marker is visible, the runner dot stands at the finish, and the race clock renders once instead of ticking. The CSS backstops it withstroke-dasharray: none !importantso no state can strand the chart half-drawn. - The pace math is a two-half model. Pick a strategy
d(the second-half pace multiplier − 1):p1 = T / (H · (2 + d)),p2 = p1 · (1 + d). The halves always sum back to the goal, so "Negative −2%" is honest arithmetic, not a label. - The clock rolls over. Race day is computed as the fourth Sunday of October; the morning after the course closes, the countdown quietly re-aims at next year. A template that shows −212 days has stopped being a template.
- Scroll work is rAF-throttled and does no layout reads beyond one
getBoundingClientRectper frame. The dash draw is GPU-cheap — it's one stroke.
The registration form is a demo — it validates, confirms in place with a fictional bib number, and sends nothing. Wire it to a real entry system before you take anyone's money.
Ship it on GitHub Pages
Vite builds into docs/ with base: '/splits/', which is exactly the
shape GitHub Pages wants for a project site served from a subpath.
npm run build # tsc --noEmit && vite build → docs/
git add docs && git commit -m "build"
gh repo create bswxyz/splits --public --source . --push
# Pages → deploy from branch → main /docs
A .nojekyll in public/ rides along into docs/ so Pages
serves the build untouched, and this guide is a plain static page in public/guide/ —
copied as-is, no bundling required.