Scroll Progress Timeline with Reveal Animations
A company story timeline where a glowing line fills as you scroll, each milestone's dot and year light up when you reach it, and cards slide in from alternating sides. Includes a hero with scroll hint, stats on every card and a closing call to action. Milestones come from one JavaScript array and the layout switches to a single column on phones.
- Published 15 Oct 2026
- 9 views
Overview
Scroll Progress Timeline with Reveal Animations
Tell your story the way people read it — the line fills as they scroll and every milestone lights up on arrival.
What's Included
- Line fills in sync with scrolling (requestAnimationFrame, passive listener)
- Dots and years light up as you pass them
- Cards slide in from alternating sides with IntersectionObserver
- Year, tag, emoji, text and stat on every milestone
- Single-column layout with the line on the left on mobile
- Milestones come from one JavaScript array
- Respects reduced motion
Dependencies
- Google Fonts — Bricolage Grotesque https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,700;12..96,800&display=swap
Already linked inside the component's files — no extra setup needed.
Desktop Preview
HTML
html1<!doctype html> 2<html lang="en"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <title>Scroll Progress Timeline | MetaSkillGroup</title> 7 <link rel="stylesheet" href="style.css" /> 8 </head> 9 <body> 10 <!-- created by metaskillgroup-team --> 11 <header class="hero"> 12 <span class="eyebrow">Our journey</span> 13 <h1>From a laptop in a hostel room to <span>40,000 learners.</span></h1> 14 <p>Scroll down — the line fills as you go and every milestone lights up when you reach it.</p> 15 <div class="scroll-hint" aria-hidden="true"><span></span></div> 16 </header> 17 18 <main class="timeline-wrap" id="timeline-wrap"> 19 <div class="track" aria-hidden="true"><span class="fill" id="fill"></span></div> 20 <!-- Milestones are rendered from script.js --> 21 <ol class="timeline" id="timeline"></ol> 22 </main> 23 24 <footer class="end"> 25 <h2>And we're just getting started.</h2> 26 <a href="#" class="cta">Join the journey →</a> 27 </footer> 28 29 <script src="script.js"></script> 30 </body> 31</html>
CSS
css1/* Importing Google Fonts - Bricolage Grotesque */ 2@import url("https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,700;12..96,800&display=swap"); 3 4* { 5 margin: 0; 6 padding: 0; 7 box-sizing: border-box; 8 font-family: "Bricolage Grotesque", sans-serif; 9} 10 11:root { 12 --bg: #0d1016; 13 --card: #151a23; 14 --card-border: #232a37; 15 --text: #f3efe6; 16 --muted: #959cab; 17 --amber: #ffb547; 18 --pink: #ff5d8f; 19 --line: #262d3a; 20} 21 22body { 23 background: var(--bg); 24 color: var(--text); 25 overflow-x: hidden; 26} 27 28/* ---------- Hero ---------- */ 29.hero { 30 display: grid; 31 place-items: center; 32 align-content: center; 33 min-height: 82vh; 34 padding: 80px 20px 40px; 35 text-align: center; 36 background: radial-gradient(circle at 50% 0%, rgba(255, 181, 71, 0.16), transparent 55%); 37} 38 39.eyebrow { 40 color: var(--amber); 41 font-size: 0.82rem; 42 font-weight: 700; 43 letter-spacing: 0.16em; 44 text-transform: uppercase; 45} 46 47.hero h1 { 48 max-width: 820px; 49 margin: 16px 0; 50 font-size: clamp(2.2rem, 6vw, 4rem); 51 font-weight: 800; 52 line-height: 1.04; 53 letter-spacing: -0.03em; 54 text-wrap: balance; 55} 56 57.hero h1 span { 58 background: linear-gradient(90deg, var(--amber), var(--pink)); 59 -webkit-background-clip: text; 60 background-clip: text; 61 color: transparent; 62} 63 64.hero p { 65 max-width: 520px; 66 color: var(--muted); 67 font-size: 1.08rem; 68 line-height: 1.6; 69} 70 71.scroll-hint { 72 width: 26px; 73 height: 42px; 74 margin-top: 40px; 75 border: 2px solid var(--line); 76 border-radius: 999px; 77} 78 79.scroll-hint span { 80 display: block; 81 width: 4px; 82 height: 8px; 83 margin: 7px auto 0; 84 border-radius: 4px; 85 background: var(--amber); 86 animation: hint 1.6s ease-in-out infinite; 87} 88 89@keyframes hint { 90 60% { 91 transform: translateY(14px); 92 opacity: 0; 93 } 94 100% { 95 opacity: 0; 96 } 97} 98 99/* ---------- Timeline ---------- */ 100.timeline-wrap { 101 position: relative; 102 max-width: 1040px; 103 margin: 0 auto; 104 padding: 40px 20px 60px; 105 overflow-x: clip; /* hidden cards start off to the side */ 106} 107 108/* Grey track + the fill that grows with scroll (height set in script.js) */ 109.track { 110 position: absolute; 111 top: 40px; 112 bottom: 60px; 113 left: 50%; 114 width: 3px; 115 border-radius: 3px; 116 background: var(--line); 117 transform: translateX(-50%); 118} 119 120.fill { 121 display: block; 122 width: 100%; 123 height: 100%; 124 border-radius: inherit; 125 background: linear-gradient(180deg, var(--amber), var(--pink)); 126 box-shadow: 0 0 18px rgba(255, 93, 143, 0.6); 127 transform: scaleY(var(--progress, 0)); 128 transform-origin: top; 129} 130 131.timeline { 132 position: relative; 133 display: grid; 134 gap: 34px; 135 list-style: none; 136} 137 138.milestone { 139 position: relative; 140 display: grid; 141 grid-template-columns: 1fr 1fr; 142 column-gap: 80px; 143} 144 145/* Node on the line */ 146.node { 147 position: absolute; 148 top: 26px; 149 left: 50%; 150 z-index: 1; 151 display: grid; 152 place-items: center; 153 width: 22px; 154 height: 22px; 155 border: 3px solid var(--line); 156 border-radius: 50%; 157 background: var(--bg); 158 transform: translateX(-50%); 159 transition: border-color 0.4s, box-shadow 0.4s, transform 0.4s; 160} 161 162.node::after { 163 content: ""; 164 width: 8px; 165 height: 8px; 166 border-radius: 50%; 167 background: var(--line); 168 transition: background 0.4s; 169} 170 171.milestone.is-reached .node { 172 border-color: var(--amber); 173 box-shadow: 0 0 0 6px rgba(255, 181, 71, 0.15), 0 0 20px rgba(255, 181, 71, 0.5); 174 transform: translateX(-50%) scale(1.1); 175} 176 177.milestone.is-reached .node::after { 178 background: var(--amber); 179} 180 181/* Cards alternate sides */ 182.card { 183 grid-column: 1; 184 position: relative; 185 padding: 24px 26px; 186 border: 1px solid var(--card-border); 187 border-radius: 20px; 188 background: var(--card); 189 opacity: 0; 190 transform: translateX(-40px); 191 transition: opacity 0.7s ease, transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1), border-color 0.4s; 192} 193 194.milestone:nth-child(even) .card { 195 grid-column: 2; 196 transform: translateX(40px); 197} 198 199.milestone.is-visible .card { 200 opacity: 1; 201 transform: none; 202} 203 204.milestone.is-reached .card { 205 border-color: rgba(255, 181, 71, 0.35); 206} 207 208/* Little arrow pointing to the line */ 209.card::before { 210 content: ""; 211 position: absolute; 212 top: 28px; 213 right: -8px; 214 width: 14px; 215 height: 14px; 216 border-top: 1px solid var(--card-border); 217 border-right: 1px solid var(--card-border); 218 background: var(--card); 219 transform: rotate(45deg); 220} 221 222.milestone:nth-child(even) .card::before { 223 right: auto; 224 left: -8px; 225 transform: rotate(-135deg); 226} 227 228/* Year shown on the opposite side of the card */ 229.year { 230 grid-row: 1; 231 grid-column: 2; 232 padding-top: 18px; 233 color: var(--line); 234 font-size: clamp(2.2rem, 5vw, 3.4rem); 235 font-weight: 800; 236 letter-spacing: -0.04em; 237 transition: color 0.5s; 238} 239 240.milestone:nth-child(even) .year { 241 grid-column: 1; 242 text-align: right; 243} 244 245.milestone.is-reached .year { 246 color: var(--text); 247} 248 249.card-top { 250 display: flex; 251 align-items: center; 252 justify-content: space-between; 253 gap: 12px; 254 margin-bottom: 10px; 255} 256 257.tag { 258 padding: 4px 10px; 259 border-radius: 999px; 260 background: rgba(255, 181, 71, 0.12); 261 color: var(--amber); 262 font-size: 0.75rem; 263 font-weight: 700; 264} 265 266.emoji { 267 font-size: 1.4rem; 268} 269 270.card h3 { 271 font-size: 1.3rem; 272 font-weight: 700; 273 letter-spacing: -0.01em; 274} 275 276.card p { 277 margin-top: 8px; 278 color: var(--muted); 279 line-height: 1.6; 280} 281 282.stat { 283 display: inline-block; 284 margin-top: 14px; 285 font-size: 0.85rem; 286 font-weight: 600; 287} 288 289.stat b { 290 color: var(--amber); 291} 292 293/* ---------- End ---------- */ 294.end { 295 padding: 40px 20px 110px; 296 text-align: center; 297} 298 299.end h2 { 300 font-size: clamp(1.6rem, 4vw, 2.4rem); 301 font-weight: 800; 302 letter-spacing: -0.02em; 303} 304 305.cta { 306 display: inline-block; 307 margin-top: 22px; 308 padding: 14px 26px; 309 border-radius: 14px; 310 background: linear-gradient(90deg, var(--amber), var(--pink)); 311 color: #1a1208; 312 font-weight: 700; 313 text-decoration: none; 314 transition: transform 0.2s; 315} 316 317.cta:hover { 318 transform: translateY(-3px); 319} 320 321/* ---------- Mobile: line on the left, all cards on the right ---------- */ 322@media (max-width: 760px) { 323 .track { 324 left: 30px; 325 } 326 327 .milestone { 328 grid-template-columns: 1fr; 329 padding-left: 44px; 330 } 331 332 .node { 333 left: 10px; 334 } 335 336 .card, 337 .milestone:nth-child(even) .card { 338 grid-column: 1; 339 grid-row: 2; 340 transform: translateX(30px); 341 } 342 343 .card::before, 344 .milestone:nth-child(even) .card::before { 345 display: none; 346 } 347 348 .year, 349 .milestone:nth-child(even) .year { 350 grid-column: 1; 351 padding: 18px 0 8px; 352 font-size: 2rem; 353 text-align: left; 354 } 355} 356 357@media (prefers-reduced-motion: reduce) { 358 .card { 359 opacity: 1; 360 transform: none !important; 361 transition: none; 362 } 363 364 .scroll-hint span { 365 animation: none; 366 } 367}
JavaScript
javascript1// Edit this list to change the milestones 2const milestones = [ 3 { year: "2019", tag: "The idea", emoji: "💡", title: "It started with a question", text: "Why is learning to code so confusing? Two friends began answering it with free YouTube videos.", stat: ["1,200", "first subscribers"] }, 4 { year: "2020", tag: "Launch", emoji: "🚀", title: "The first course goes live", text: "A 30-day JavaScript bootcamp, built in a hostel room on a 4 GB laptop.", stat: ["3,500", "students enrolled"] }, 5 { year: "2021", tag: "Community", emoji: "🤝", title: "Learners start helping learners", text: "A Discord server turned into a 24/7 help desk run by the students themselves.", stat: ["8,000", "community members"] }, 6 { year: "2022", tag: "Internships", emoji: "🎓", title: "Real projects, real mentors", text: "We launched mentored internships so learners could ship work for real clients.", stat: ["450", "interns placed"] }, 7 { year: "2024", tag: "Growth", emoji: "📈", title: "A team of 25", text: "From two friends to a remote team across six cities, still obsessed with clarity.", stat: ["25,000", "learners"] }, 8 { year: "2026", tag: "Today", emoji: "✨", title: "40,000 learners and counting", text: "Free components, templates and courses — all built to help you learn by doing.", stat: ["40,000+", "learners worldwide"] }, 9]; 10 11const list = document.getElementById("timeline"); 12const wrap = document.getElementById("timeline-wrap"); 13const fill = document.getElementById("fill"); 14 15list.innerHTML = milestones 16 .map( 17 (m) => ` 18 <li class="milestone"> 19 <span class="node" aria-hidden="true"></span> 20 <article class="card"> 21 <div class="card-top"><span class="tag">${m.tag}</span><span class="emoji" aria-hidden="true">${m.emoji}</span></div> 22 <h3>${m.title}</h3> 23 <p>${m.text}</p> 24 <span class="stat"><b>${m.stat[0]}</b> ${m.stat[1]}</span> 25 </article> 26 <span class="year">${m.year}</span> 27 </li>`, 28 ) 29 .join(""); 30 31const items = [...list.querySelectorAll(".milestone")]; 32 33// Cards slide in the first time they enter the screen 34const revealer = new IntersectionObserver( 35 (entries) => { 36 entries.forEach((entry) => { 37 if (entry.isIntersecting) { 38 entry.target.classList.add("is-visible"); 39 revealer.unobserve(entry.target); 40 } 41 }); 42 }, 43 { threshold: 0.25 }, 44); 45items.forEach((item) => revealer.observe(item)); 46 47// Fill the line up to the middle of the screen and light up passed nodes 48const track = document.querySelector(".track"); 49let ticking = false; 50 51const update = () => { 52 ticking = false; 53 const rect = track.getBoundingClientRect(); 54 const marker = window.innerHeight * 0.55; // the "reading line" 55 const progress = Math.min(Math.max((marker - rect.top) / rect.height, 0), 1); 56 fill.style.setProperty("--progress", progress); 57 58 items.forEach((item) => { 59 const node = item.querySelector(".node").getBoundingClientRect(); 60 item.classList.toggle("is-reached", node.top + node.height / 2 <= marker); 61 }); 62}; 63 64const onScroll = () => { 65 if (!ticking) { 66 ticking = true; 67 requestAnimationFrame(update); 68 } 69}; 70 71window.addEventListener("scroll", onScroll, { passive: true }); 72window.addEventListener("resize", onScroll); 73update();
Built by MetaSkillGroup — copy it, use it, make it yours.
Features
Line fills in sync with scrolling (requestAnimationFrame, passive listener)
Dots and years light up as you pass them
Cards slide in from alternating sides with IntersectionObserver
Year, tag, emoji, text and stat on every milestone
Single-column layout with the line on the left on mobile
Milestones come from one JavaScript array
Respects reduced motion
Built With
HTML5
CSS3
JavaScript
Compatibility
❓ FAQs
Edit the milestones array at the top of script.js. Each one has a year, tag, emoji, title, text and a stat pair like ["3,500", "students enrolled"].
Yes — the 'reading line' is at 55% of the screen height. Change the 0.55 in update() inside script.js.
Yes. Below 760px the line moves to the left and every card sits on the right, one column.

Written by
MetaSkillGroup
Need it built for you?
Built to the same standard as our client work.
If nothing here fits exactly, tell us what you need — our core developers build it. Requirements call first, a written SRS before any code, and a price agreed up front.