Count-up stats
Numbers roll up from zero with an ease-out curve the moment they come into view.
A winding route draws itself down the page, and checkpoints light up as the line reaches them.
The path’s total length comes from getTotalLength(). It is used for stroke-dasharray, and scroll progress sets stroke-dashoffset. Each checkpoint stores its position along the path, and activates once the drawn length passes it.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SVG route draw</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; }
body { background: #0b3d2e; color: #e6ffe9; }
.track { padding: 10vh 0 30vh; }
.map { display: block; width: min(90vw, 420px); margin: 0 auto; overflow: visible; }
.ghost { fill: none; stroke: rgba(230,255,233,.15); stroke-width: 4; stroke-dasharray: 2 10; stroke-linecap: round; }
.route { fill: none; stroke: #ffd166; stroke-width: 6; stroke-linecap: round; }
.pt circle { fill: #0b3d2e; stroke: rgba(230,255,233,.3); stroke-width: 4; transition: fill .3s, stroke .3s; }
.pt text { fill: #e6ffe9; font: 600 18px system-ui, sans-serif; opacity: .3; transition: opacity .3s; }
.pt.on circle { fill: #ffd166; stroke: #ffd166; }
.pt.on text { opacity: 1; }
</style>
</head>
<body>
<section class="track">
<svg class="map" viewBox="0 0 400 1200" preserveAspectRatio="xMidYMin meet">
<path class="ghost" d="M200 20 C 380 120, 20 220, 200 320 S 380 520, 200 620 S 20 820, 200 920 S 360 1120, 200 1180"/>
<path class="route" id="route" d="M200 20 C 380 120, 20 220, 200 320 S 380 520, 200 620 S 20 820, 200 920 S 360 1120, 200 1180"/>
<g id="pts"></g>
</svg>
</section>
<script>
const route = document.getElementById('route');
const pts = document.getElementById('pts');
const L = route.getTotalLength();
route.style.strokeDasharray = L;
route.style.strokeDashoffset = L;
const names = ['Start', 'Bridge', 'Forest', 'Lake', 'Ridge', 'Summit'];
const marks = names.map((n, i) => {
const at = (L * i) / (names.length - 1);
const p = route.getPointAtLength(at);
const side = p.x > 200 ? 18 : -18;
pts.insertAdjacentHTML('beforeend', '<g class="pt"><circle cx="' + p.x + '" cy="' + p.y + '" r="11"/><text x="' + (p.x + side) + '" y="' + (p.y + 6) + '" text-anchor="' + (side > 0 ? 'start' : 'end') + '">' + n + '</text></g>');
return { at, el: pts.lastElementChild };
});
function update() {
const r = route.getBoundingClientRect();
const p = Math.min(1, Math.max(0, (innerHeight * 0.6 - r.top) / r.height));
const drawn = p * L;
route.style.strokeDashoffset = L - drawn;
marks.forEach(m => m.el.classList.toggle('on', drawn >= m.at - 1));
}
addEventListener('scroll', update, { passive: true });
update();
</script>
</body>
</html>