[ Parable build guide ]
How Halfstep was built
One e-bike, taken apart by the scrollbar. The signature is an exploded-assembly scroll: an inline-SVG service diagram whose six assemblies fly out into a labelled exploded view as you scroll, then bolt themselves back together — plus a configurator that repaints every diagram on the page live.
The idea
Every e-bike site shows you a glamour render and a spec table, far apart, hoping you won't compare them. Halfstep's voice is "specs stated plainly" — so the centrepiece states them on the parts themselves. A service diagram is the most honest drawing a bike company can publish: if it comes apart cleanly in the diagram, it comes apart cleanly in your kitchen. The scroll direction maps to disassembly, the labels are printed where the torque actually happens, and reassembly at the end is the quiet warranty pitch.
The stack
Vite with vanilla TypeScript — no framework. The page is static HTML with a handful of small
modules: explode.ts (the flourish), configurator.ts and
rangecalc.ts (which share one battery value through a ten-line
state.ts), reveal.ts, theme.ts, form.ts.
The bike itself is hand-plotted inline SVG — strokes with round caps for tubes, circles for
wheels — so the paint colour is just a CSS variable (--paint) the configurator flips
with data-paint on <html>. Type is Archivo at 900 for the
engineered shouting, Manrope for body, Space Mono for everything a torque wrench would say.
Signature technique — the exploded-assembly scroll
The build section is a tall scroller (~3.4 viewports) with a position: sticky
stage pinned inside it. Scroll progress p maps to three phases — fly out, hold the
labelled diagram, fly home. Each part is an SVG <g> with an exploded offset
and a stagger delay, so the wheels leave first and return last, like an actual teardown:
// global explode amount 0 → 1 → 0 across the three phases
let eRaw: number;
if (p < 0.4) eRaw = easeInOutCubic(p / 0.4); // coming apart
else if (p < 0.6) eRaw = 1; // exploded, labelled
else eRaw = 1 - easeInOutCubic((p - 0.6) / 0.4); // reassembling
for (const w of wired) {
const local = clamp(eRaw * (1 + STAGGER) - w.def.delay, 0, 1);
const e = easeOutCubic(local);
w.node.style.transform =
`translate(${dx * e}px, ${dy * e}px) rotate(${rot * e}deg)`;
// the callout's leader line chases its part
w.line.setAttribute('x2', String(ax + dx * e));
w.line.setAttribute('y2', String(ay + dy * e));
}
Because CSS pixel translations on SVG children resolve in user units, the same offsets work at
every screen size. The callout leader lines are authored in the markup pointing at the
assembled anchors, so with JavaScript disabled — or reduced motion on — the diagram is
simply a labelled side view. The belt and ground shadows fade out with 1 − eRaw,
because exploded parts don't cast shadows.
Details that matter
- One value, two widgets. The configurator's battery choice feeds the range calculator through a tiny pub-sub — pick 630 Wh up there, the estimate down here updates and says whose pack it's quoting.
- Honest arithmetic. The range formula (pack ÷ Wh/km × weight × terrain) is the whole calculator — no fake precision, and the fine print admits what winter costs.
- Work only when watched. The render is six transforms and a few attributes —
cheap enough to run straight from the scroll event — and an
IntersectionObserverdisables it entirely while the stage is off-screen. - Reduced motion is a design, not a fallback. The scroller collapses to a static labelled diagram, reveals and counters resolve instantly, and the configurator swaps prices without the count-up.
- Lime that can read. Hi-viz
#c7f04afails contrast on bone, so it only ever appears as fills and marks; text gets--acc-text— moss-dark in light mode, the full lime on graphite. - Nothing hides without JS. Reveals and the hero rise are gated behind a
.jsclass set synchronously in<head>.
The reserve form is a demo — it validates and confirms in place but sends nothing. Wire it to your own endpoint (and a real deposit flow) before taking anyone's €100.
Ship it on GitHub Pages
Vite builds into docs/ with base: '/halfstep/', so Pages can serve
the project subpath straight from the branch:
npm run build # tsc --noEmit && vite build → docs/
gh repo create bswxyz/halfstep --public --source . --push
gh api --method POST /repos/bswxyz/halfstep/pages \
-f 'source[branch]=main' -f 'source[path]=/docs'
The public/ folder carries .nojekyll and this guide verbatim into the
build, so the whole site — diagram, configurator, guide — is one push.