An interactive scroll-based timeline that visually guides users through each step with smooth, progressive animations.
<!-- GSAP -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script><script>
document.addEventListener("DOMContentLoaded", () => {
// Safety check — exit if GSAP or ScrollTrigger isn't loaded
if (!window.gsap || !window.ScrollTrigger) return;
// Register ScrollTrigger plugin
gsap.registerPlugin(ScrollTrigger);
// Run animations only on desktop screens
ScrollTrigger.matchMedia({
"(min-width: 992px)": function () {
// Loop through each timeline instance on the page
gsap.utils.toArray("[timeline-wrapper]").forEach((wrapper) => {
/* -------------------------------------------------
ELEMENT SELECTORS
-------------------------------------------------- */
// Main horizontal progress bar
const bar = wrapper.querySelector("[timeline-bar-grow]");
// Timeline content blocks (limited to first 4)
const blocks = gsap.utils.toArray(
wrapper.querySelectorAll("[timeline-block]")
).slice(0, 4);
// Timeline step numbers
const numbers = gsap.utils.toArray(
wrapper.querySelectorAll("[timeline-numbers]")
).slice(0, 4);
// Vertical timeline lines (each contains [timeline-line-grow])
const lines = gsap.utils.toArray(
wrapper.querySelectorAll("[timeline-lines]")
).slice(0, 4);
// Exit early if nothing relevant exists
if (!bar && !blocks.length && !lines.length && !numbers.length) return;
/* -------------------------------------------------
INITIAL STATES (before scrolling)
-------------------------------------------------- */
// Bar starts empty
if (bar) gsap.set(bar, { width: "0%" });
// Blocks start slightly faded
if (blocks.length) gsap.set(blocks, { opacity: 0.5 });
// Numbers start visually "off" using brightness
if (numbers.length) gsap.set(numbers, { filter: "brightness(0)" });
// Vertical lines start collapsed
lines.forEach(line => {
const grow = line.querySelector("[timeline-line-grow]");
if (grow) gsap.set(grow, { height: "0%" });
});
/* -------------------------------------------------
SCROLL-DRIVEN TIMELINE
-------------------------------------------------- */
const tl = gsap.timeline({
scrollTrigger: {
trigger: wrapper, // Element that controls scroll
start: "top top", // When wrapper hits top of viewport
end: "bottom bottom", // When wrapper leaves viewport
scrub: true, // Tie animation directly to scroll
invalidateOnRefresh: true // Recalculate on resize
// markers: true // Uncomment for debugging
}
});
// Fill the main bar evenly across the full scroll distance
if (bar) {
tl.to(bar, {
width: "100%",
ease: "none",
duration: 1
}, 0);
}
/* -------------------------------------------------
TIMING CONTROLS
- quarter: how much scroll each step takes
- touchWindow: how close to the end a line activates
-------------------------------------------------- */
const quarter = 0.25; // Each step gets 25% of the scroll
const touchWindow = 0.25; // Timing of each line: lower = faster
// Loop through the 4 timeline steps
for (let i = 0; i < 4; i++) {
// Start time for this step in the timeline
const startT = i * quarter;
/* ---------------------------------------------
BLOCK OPACITY
Gradually becomes fully visible during its quarter
---------------------------------------------- */
if (blocks[i]) {
tl.to(
blocks[i],
{
opacity: 1,
ease: "none",
duration: quarter
},
startT
);
}
/* ---------------------------------------------
NUMBER BRIGHTNESS
Syncs exactly with block opacity
---------------------------------------------- */
if (numbers[i]) {
tl.to(
numbers[i],
{
filter: "brightness(1)",
ease: "none",
duration: quarter
},
startT
);
}
/* ---------------------------------------------
LINE GROWTH
Activates only when the bar "touches" it
---------------------------------------------- */
if (lines[i]) {
const grow = lines[i].querySelector("[timeline-line-grow]");
if (!grow) continue;
// End of this quarter (0.25, 0.5, 0.75, 1)
const quarterEnd = (i + 1) * quarter;
// Start slightly before the quarter ends
const lineStart = Math.max(0, quarterEnd - touchWindow);
// Short animation window for the line growth
const lineDur = Math.min(touchWindow, quarterEnd);
tl.to(
grow,
{
height: "100%",
ease: "none",
duration: lineDur
},
lineStart
);
}
}
});
// Ensure ScrollTrigger recalculates after setup
ScrollTrigger.refresh();
}
});
});
</script>Scroll-Driven Timeline Animation Guide
This script controls a desktop-only, scroll-synced timeline animation where progress, content, numbers, and connecting lines animate in sequence as the user scrolls through the section.
What this script controls
Desktop-only behavior
This animation only runs on desktop screens (992px and above).
On tablet and mobile:
The script automatically refreshes itself when resizing back to desktop.
Attribute Breakdown & Behavior
[timeline-wrapper]
Purpose:
Acts as the scroll trigger and container for a single timeline instance.
Behavior:
[timeline-bar-grow]
Purpose:
Main horizontal progress bar.
Behavior on scroll:
0% width100% across the full scroll rangeWhy this exists:
Provides a visual indicator of progress through the timeline.
[timeline-block]
Purpose:
Content blocks representing each timeline step.
Behavior on scroll:
Important notes:
[timeline-numbers]
Purpose:
Step numbers associated with each timeline block.
Behavior on scroll:
Why this exists:
Helps reinforce which step is currently active as the user scrolls.
[timeline-lines]
Purpose:
Vertical connector lines between timeline steps.
Each .timeline-lines element contains:
.timeline-line-grow (the animated inner line)Behavior on scroll:
height: 0%)100%Why this exists:
Creates a clean, step-by-step connection between timeline stages.
Scroll timing logic
The full scroll range of the timeline is divided evenly into four segments:
ease: none to remain perfectly synced to scrollThis ensures:
Customization tips
Join 1000+ developers getting weekly UI inspiration. No spam, ever.