Skip to content
UI ComponentTimelineData DisplayNew

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
Scroll Progress Timeline with Reveal Animations — preview

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

Already linked inside the component's files — no extra setup needed.


Desktop Preview


HTML

html
1<!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

css
1/* 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

javascript
1// 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

Browsers
ChromeEdgeFirefoxSafariOperaBraveArcVivaldi
Devices
DesktopTabletMobile
Frameworks
Vanilla JSHTML/CSS

❓ 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.

MetaSkillGroup

Written by

MetaSkillGroup

October 15, 2026

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.

Scroll Progress Timeline (HTML CSS JS) | MetaSkillGroup