Reading progress bar
A thin bar across the top fills as you move through an article.
A centre line fills downward as you read, and each milestone lights up when the line reaches it.
The line’s fill is a scaleY driven by how far the viewport centre has travelled through the timeline. Each entry compares its own dot position with that same point, so dots and cards switch on exactly as the line touches them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vertical timeline</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: #fdfcfa; color: #22223b; }
.hd { height: 50vh; display: grid; place-content: center; text-align: center; }
.hd h1 { font-size: 3rem; margin: 0; }
.tl { position: relative; max-width: 900px; margin: 0 auto; padding: 0 24px; }
.line { position: absolute; left: 50%; top: 0; bottom: 0; width: 4px; margin-left: -2px; background: #e6e3ef; border-radius: 2px; }
.line b { display: block; height: 100%; background: #7b2cbf; transform-origin: top; transform: scaleY(0); border-radius: 2px; }
.ev { position: relative; width: 50%; padding: 0 40px 12vh; }
.ev:nth-child(odd) { margin-left: 50%; }
.ev:nth-child(even) { text-align: right; }
.ev::before { content: ''; position: absolute; top: 6px; width: 18px; height: 18px; border-radius: 50%; background: #fdfcfa; border: 4px solid #e6e3ef; transition: border-color .3s, background .3s; }
.ev:nth-child(odd)::before { left: -11px; }
.ev:nth-child(even)::before { right: -11px; }
.ev h3 { margin: 0 0 6px; font-size: 1.6rem; color: #b8b5c6; transition: color .3s; }
.ev p { margin: 0; color: #8a879a; }
.ev.on::before { border-color: #7b2cbf; background: #7b2cbf; }
.ev.on h3 { color: #22223b; }
@media (max-width: 600px) { .line { left: 20px; } .ev, .ev:nth-child(odd) { width: auto; margin-left: 0; padding-left: 52px; text-align: left; } .ev::before, .ev:nth-child(odd)::before, .ev:nth-child(even)::before { left: 9px; right: auto; } }
</style>
</head>
<body>
<header class="hd"><h1>Our story</h1></header>
<section class="tl" id="tl">
<div class="line"><b id="fill"></b></div>
</section>
<footer class="hd"><p>To be continued.</p></footer>
<script>
const events = [['2019', 'A sketch on a napkin'], ['2020', 'First prototype'], ['2021', 'Ten users, then a hundred'], ['2022', 'The team grows to five'], ['2023', 'First office'], ['2024', 'Version two'], ['2025', 'One million scrolls'], ['2026', 'Still scrolling']];
const tl = document.getElementById('tl');
events.forEach(([y, t]) => tl.insertAdjacentHTML('beforeend', '<div class="ev"><h3>' + y + '</h3><p>' + t + '</p></div>'));
const fill = document.getElementById('fill');
const evs = tl.querySelectorAll('.ev');
function update() {
const r = tl.getBoundingClientRect();
const mid = innerHeight * 0.55;
const p = Math.min(1, Math.max(0, (mid - r.top) / r.height));
fill.style.transform = 'scaleY(' + p.toFixed(4) + ')';
evs.forEach(ev => ev.classList.toggle('on', ev.getBoundingClientRect().top + 12 <= mid));
}
addEventListener('scroll', update, { passive: true });
update();
</script>
</body>
</html>