Count-up stats
Numbers roll up from zero with an ease-out curve the moment they come into view.
A pinned line chart draws itself with your scroll, and a marker reads out the value at the tip.
The data is turned into an SVG path. Scroll progress sets stroke-dashoffset to reveal a share of its length, and getPointAtLength() gives the tip position for the marker. The label interpolates between the two nearest data points so the number changes smoothly.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scrubbed line chart</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: #fafaf7; color: #1b263b; }
.track { height: 320vh; }
.stage { position: sticky; top: 0; height: 100vh; display: grid; align-content: center; padding: 0 6vw; gap: 16px; }
.stage h2 { margin: 0; font: 800 clamp(1.6rem, 4vw, 2.4rem) system-ui, sans-serif; }
.chart { width: 100%; height: 55vh; overflow: visible; }
.chart line { stroke: #e3e3dc; stroke-width: 1; vector-effect: non-scaling-stroke; }
.line { fill: none; stroke: #e63946; stroke-width: 3; vector-effect: non-scaling-stroke; stroke-linejoin: round; }
.area { fill: rgba(230,57,70,.08); }
#dot { fill: #e63946; stroke: #fff; stroke-width: 3; vector-effect: non-scaling-stroke; }
.readout { margin: 0; display: flex; gap: 14px; align-items: baseline; font: 600 1.1rem system-ui, sans-serif; }
.readout b { font-size: 2.4rem; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<section class="track">
<div class="stage">
<h2>Monthly readers</h2>
<svg class="chart" id="chart" viewBox="0 0 600 300" preserveAspectRatio="none">
<g id="grid"></g>
<path id="area" class="area"/>
<path id="line" class="line"/>
<circle id="dot" r="7"/>
</svg>
<p class="readout"><span id="month">Jan</span><b id="val">0</b></p>
</div>
</section>
<script>
const data = [12, 18, 15, 26, 31, 28, 40, 46, 43, 58, 66, 80];
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const W = 600, H = 300, max = 90;
const pts = data.map((v, i) => [i * W / (data.length - 1), H - v / max * H]);
const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0] + ' ' + p[1]).join(' ');
const line = document.getElementById('line'), area = document.getElementById('area'), dot = document.getElementById('dot');
line.setAttribute('d', d);
const grid = document.getElementById('grid');
for (let y = 0; y <= 3; y++) grid.insertAdjacentHTML('beforeend', '<line x1="0" x2="600" y1="' + (y * 100) + '" y2="' + (y * 100) + '"/>');
const L = line.getTotalLength();
line.style.strokeDasharray = L;
const month = document.getElementById('month'), val = document.getElementById('val');
const track = document.querySelector('.track');
function update() {
const r = track.getBoundingClientRect();
const p = Math.min(1, Math.max(0, -r.top / (r.height - innerHeight)));
const len = p * L;
line.style.strokeDashoffset = L - len;
const tip = line.getPointAtLength(len);
dot.setAttribute('cx', tip.x); dot.setAttribute('cy', tip.y);
area.setAttribute('d', d.split(' L').slice(0, Math.floor(tip.x / (W / (data.length - 1))) + 1).join(' L') + ' L' + tip.x + ' ' + tip.y + ' L' + tip.x + ' ' + H + ' L0 ' + H + ' Z');
const f = tip.x / (W / (data.length - 1)), i = Math.min(data.length - 2, Math.floor(f)), k = f - i;
val.textContent = Math.round(data[i] + (data[i + 1] - data[i]) * k) + 'k';
month.textContent = months[Math.round(f)];
}
addEventListener('scroll', update, { passive: true });
update();
</script>
</body>
</html>