A client recently asked me to "add some life" to their website's process section. What they had was a static vertical timeline showing their company milestones - functional but forgettable. I knew immediately that a scroll-driven animation would transform this from something users scroll past into something they actually engage with.
The challenge wasn't just making things move. I needed pixel-perfect positioning that adapts to dynamic content heights, synchronized multi-layer animations across dots, lines, cards, and labels, and smooth 60fps performance. After implementing it successfully, I want to share the complete process with you.
By the end of this guide, you'll know how to build a production-ready scroll-driven timeline that pins to the viewport, animates progressively as users scroll, and handles all the edge cases that make the difference between a demo and something you'd actually ship.
Understanding the Architecture
Before diving into code, let's understand what we're building. The timeline consists of several animated layers that need to work in perfect harmony. There's a background line that spans from the first to last timeline dot, a progress line that grows as you scroll, timeline dots that expand and fill with color, content cards that scale and change background, and date labels that grow and become bold.
The key insight here is that everything is driven by scroll position. We're not using time-based animations or click events. As the user scrolls, GSAP's ScrollTrigger plugin maps that scroll distance to animation progress, creating a scrubbing effect that feels responsive and under the user's control.
Setting Up the Component Foundation
Let's start with the basic component structure and essential imports. This gives us the foundation we'll build upon.
Notice we're using useLayoutEffect instead of useEffect. This is critical because we need to perform DOM measurements with getBoundingClientRect() before the browser paints. Using useEffect would cause a visible flash as elements snap into position after the initial render.
The three refs we've created represent the main containers we'll need to reference throughout our animations. The container ref is the main wrapper that gets pinned during scroll, the timeline ref holds all the step content, and the background line ref is the gray static line that our progress line will grow over.
Calculating Dynamic Line Positions
One of the trickiest parts of building a timeline is getting the connecting line to start and end at exactly the right positions. CSS-only solutions fail here because they can't account for dynamic content heights and padding. We need JavaScript to measure the actual rendered positions.
typescript
// File: src/components/blocks/process/process-template-2/index.tsxuseLayoutEffect(() => {
if (!containerRef.current || !timelineRef.current) return;
const ctx = gsap.context(() => {
// Calculate and set background line height from first to last dotif (backgroundLineRef.current && steps.length > 0) {
const timelineContainer = timelineRef.current;
const firstDot = containerRef.current?.querySelector(
`[data-step="0"] .timeline-dot`,
);
const lastDot = containerRef.current?.querySelector(
`[data-step="${steps.length - 1}"] .timeline-dot`,
);
const progressLine = containerRef.current?.querySelector(
".timeline-progress-line",
) asHTMLElement;
if (firstDot && lastDot && timelineContainer) {
const firstDotRect = firstDot.getBoundingClientRect();
const lastDotRect = lastDot.getBoundingClientRect();
const timelineRect = timelineContainer.getBoundingClientRect();
// Calculate distance between center of first and last dotconst lineHeight =
lastDotRect.top +
lastDotRect.height / 2 -
(firstDotRect.top + firstDotRect.height / 2);
// Calculate top offset relative to the timeline containerconst topOffset =
firstDotRect.top + firstDotRect.height / 2 - timelineRect.top;
backgroundLineRef.current.style.height = `${lineHeight}px`;
backgroundLineRef.current.style.top = `${topOffset}px`;
if (progressLine) {
progressLine.style.top = `${topOffset}px`;
}
}
}
// More animation code will follow...
}, containerRef);
return() => ctx.revert();
}, [steps]);
This calculation is the foundation of pixel-perfect positioning. We're finding the center point of the first dot and the center point of the last dot, then calculating the exact distance between them. The top offset positions the line relative to its container, not the viewport, which is crucial when the section gets pinned during scroll.
The gsap.context() wrapper is important for cleanup. When the component unmounts or the steps change, calling ctx.revert() automatically kills all animations and removes all ScrollTriggers created within that context. This prevents memory leaks and orphaned event listeners.
Managing Initial State
A common mistake when building scroll animations is letting elements appear in their default state before animations kick in. Users see a flash of fully visible content that then suddenly becomes invisible and animates in. We need to set the initial state explicitly before any animations run.
By mapping through steps and gathering all the DOM elements we'll need to animate, we avoid repeated querySelector calls later. The initial state sets everything to invisible and slightly below where it should be for cards and dates, while dots start at scale zero. This creates a subtle slide-up effect when things fade in.
The background line starts with scaleY: 0, meaning it's collapsed to zero height. We'll reveal this as the progress line grows, but importantly, we won't reveal it during the first step animation. This prevents a visual glitch where users would see the gray line appear before the blue progress line starts filling.
Creating the Master Timeline
Now we create the main timeline that controls the entire scroll experience. This timeline will be scrubbed by the user's scroll position, meaning dragging the scrollbar back and forth will move the animation forward and backward.
The scroll distance calculation multiplies the number of steps by 1800 pixels. This means for a five-step timeline, users will scroll 9000 pixels while the section is pinned. This might seem like a lot, but it gives each animation time to breathe and prevents the rushed feeling you get when animations happen too quickly.
The scrub: 0.5 parameter adds a slight smoothing delay. Instead of animations instantly reflecting scroll position, there's a half-second catch-up period. This creates a more polished, intentional feel rather than the jittery effect of direct coupling.
The header fade happens at position 0, meaning it starts immediately as the user begins scrolling into the pinned section. We want the header out of the way quickly so users can focus on the timeline content.
Animating the First Step
The first step needs special treatment. Unlike subsequent steps where a progress line grows to reach them, the first step should be immediately visible when the section pins. It's the anchor point that everything else builds from.
typescript
// File: src/components/blocks/process/process-template-2/index.tsx (continuing)if (stepsElements[0]) {
// Fade in the first card and date
masterTl.to(
[stepsElements[0].card],
{
opacity: 1,
y: 0,
duration: 0.6,
},
0,
);
if (stepsElements[0].date) {
masterTl.to(
stepsElements[0].date,
{
opacity: 1,
y: 0,
duration: 0.6,
},
0,
);
}
// Fade in and scale up the first dot
masterTl.to(
[stepsElements[0].dot],
{
opacity: 1,
scale: 1,
duration: 0.4,
},
0,
);
// Animate dot outer ring (border expansion)
masterTl.to(
[stepsElements[0].dot],
{
borderColor: "#008ccc",
scale: 1.8,
duration: 0.5,
},
0.2,
);
// Animate dot inner fillif (stepsElements[0].dotInner) {
masterTl.to(
stepsElements[0].dotInner,
{
backgroundColor: "#008ccc",
scale: 1,
duration: 0.5,
},
0.2,
);
}
// Animate card with scale and background change
masterTl.to(
[stepsElements[0].card],
{
backgroundColor: "#f8f9fa",
scale: 1.05,
duration: 0.8,
},
0.2,
);
// Animate border fill effectconst cardBorder0 = containerRef.current?.querySelector(
`[data-step="0"] .card-border`,
);
if (cardBorder0) {
masterTl.to(
cardBorder0,
{
"--border-progress": "100%",
"--border-width": "2px",
opacity: 1,
duration: 0.8,
},
0.2,
);
}
if (stepsElements[0].date) {
masterTl.to(
stepsElements[0].date,
{
scale: 1.5,
color: "#008ccc",
fontWeight: 700,
duration: 0.5,
},
0.2,
);
}
}
This sequence demonstrates how to layer multiple animations on the same timeline. Everything starts at position 0, meaning these animations begin immediately when the section pins. The card and date fade in first, establishing the content. Then the dot appears and scales to full size.
At position 0.2 (0.2 seconds into the timeline), the activation animations kick in. The dot border expands and turns blue, the inner dot fills with color, the card scales up slightly and changes background, the border animates around the card edges, and the date label grows and becomes bold. These all happen simultaneously, creating a cohesive "activation" moment.
Notice we're not animating the background line here. This is intentional and prevents a visual glitch. If we grew the background line during the first step, users would see the gray line appear between the first and second dots before the blue progress line had a chance to grow. By keeping the background line collapsed until we transition to the second step, we ensure smooth visual progression.
Animating Subsequent Steps
For steps after the first, we need a different pattern. The progress line grows to reach the next dot, then that step activates with the same layered animation approach we used for the first step.
typescript
// File: src/components/blocks/process/process-template-2/index.tsx (continuing)const progressLine = containerRef.current?.querySelector(
".timeline-progress-line",
) asHTMLElement;
const backgroundLine = backgroundLineRef.current;
const maxHeight = backgroundLine ? parseFloat(backgroundLine.style.height) : 0;
steps.slice(1).forEach((_, i) => {
const realIndex = i + 1;
const element = stepsElements[realIndex];
if (!element) return;
const { dot, dotInner, card, date } = element;
// Calculate target height as a fraction of the background line's max heightconst targetHeight = (realIndex / (totalSteps - 1)) * maxHeight;
// Animate line to the next dotif (progressLine && maxHeight > 0) {
masterTl.to(progressLine, {
height: `${targetHeight}px`,
duration: 2,
ease: "none",
});
}
// Fade in the card and dateif (card) {
masterTl.to(
card,
{
opacity: 1,
y: 0,
duration: 0.6,
},
"-=0.4",
);
}
if (date) {
masterTl.to(
date,
{
opacity: 1,
y: 0,
duration: 0.6,
},
"-=0.4",
);
}
// Fade in and scale up the dotif (dot) {
masterTl.to(
dot,
{
opacity: 1,
scale: 1,
duration: 0.4,
},
"-=0.4",
);
}
// Animate the dot border expansionif (dot) {
masterTl.to(
dot,
{
borderColor: "#008ccc",
scale: 1.8,
duration: 0.5,
},
"-=0.2",
);
}
// Animate inner dot fill (sync with dot border)if (dotInner) {
masterTl.to(
dotInner,
{
backgroundColor: "#008ccc",
scale: 1,
duration: 0.5,
},
"<",
);
}
// Animate card with scale and background changeif (card) {
masterTl.to(
card,
{
backgroundColor: "#f8f9fa",
scale: 1.05,
duration: 0.8,
},
"<",
);
}
// Animate border fill effectconst cardBorder = containerRef.current?.querySelector(
`[data-step="${realIndex}"] .card-border`,
);
if (cardBorder) {
masterTl.to(
cardBorder,
{
"--border-progress": "100%",
"--border-width": "2px",
opacity: 1,
duration: 0.8,
},
"<",
);
}
// Animate date scale and colorif (date) {
masterTl.to(
date,
{
scale: 1.5,
color: "#008ccc",
fontWeight: 700,
duration: 0.5,
},
"<",
);
}
});
The target height calculation is crucial. We divide the current step index by totalSteps - 1, not totalSteps. This ensures that when we reach the final step, the progress line extends exactly 100% of the background line height, perfectly reaching the last dot's center. Using totalSteps instead would leave a gap.
Timeline positioning is controlled through those string parameters like -=0.4 and <. The -=0.4 means start 0.4 seconds before the previous animation ends, creating overlap. The < means start at the same time as the previous animation, creating perfect synchronization. This is how we layer the activation animations to happen simultaneously.
The line animation uses ease: 'none' for linear progression, while the activation animations use GSAP's default ease for more natural movement. This contrast makes the line feel mechanical and consistent while the content feels organic and alive.
Handling the Ending
After all steps have animated, we want to reveal a call-to-action button and give users time to absorb the final state before unpinning the section.
typescript
// File: src/components/blocks/process/process-template-2/index.tsx (continuing)// Reveal CTA button at the endconst ctaButton = containerRef.current?.querySelector(".timeline-cta");
if (ctaButton) {
masterTl.to(ctaButton, {
opacity: 1,
y: 0,
duration: 0.5,
});
}
// Add a longer hold phase at the end
masterTl.to({}, { duration: 2 });
// Handle content scrolling if it's taller than viewportconst contentHeight = timelineRef.current?.scrollHeight || 0;
const windowHeight = window.innerHeight;
if (contentHeight > windowHeight) {
masterTl.to(
timelineRef.current,
{
y: -(contentHeight - windowHeight + 300),
ease: "none",
duration: masterTl.duration(),
},
0,
);
}
The empty object animation masterTl.to({}, { duration: 2 }) is a clever trick for adding pause time. We're animating nothing for 2 seconds, which extends the timeline duration and gives users more scroll distance to view the completed state before the section unpins.
The content overflow handling is essential for timelines with many steps. If the timeline content is taller than the viewport, we smoothly scroll that content vertically throughout the entire animation. The position parameter of 0 means this happens throughout the entire timeline, not at a specific point. The +300 pixels ensures the CTA button at the bottom is fully visible.
Building the Component Structure
Now let's build the JSX that creates the actual timeline structure. This needs to support the alternating layout where even-indexed items show content on the left and dates on the right, while odd-indexed items flip this arrangement.
The card border effect uses CSS custom properties that we animate with GSAP. By animating --border-progress from 0% to 100%, we create a progressive reveal effect where the border draws itself around all four edges simultaneously. This is more performant than animating individual border properties and creates a more cohesive visual effect.
The origin-center class is crucial for the scale animations to look natural. Without it, elements would scale from the top-left corner, creating an awkward diagonal growth. With center origin, they grow evenly from their center point.
Rendering the Timeline
The final piece is assembling everything into the complete component return statement.
The data-step attribute on each timeline item is how our animation code finds the right elements to animate. The three-column grid layout with grid-cols-[1fr_auto_1fr] creates equal-width columns on the left and right with a minimal center column for the dots.
The mobile fallback below the md:hidden breakpoint provides a simple static timeline for smaller screens. We're not trying to force scroll animations on mobile where they often feel awkward and perform poorly. Instead, we highlight the first step with blue coloring and present a clean, scannable vertical list.
Key Takeaways
Building this scroll-driven timeline taught me that the difference between a cool demo and production-ready code comes down to handling edge cases. The pixel-perfect positioning ensures the line always connects dots perfectly regardless of content height. The initial state management prevents visual flashes during load. The background line delay on the first step prevents a subtle but noticeable glitch. The overflow handling ensures everything works even with many steps.
You now have a complete understanding of how to build scroll-driven timelines with GSAP and React. You know how to calculate dynamic positions, manage animation sequencing, handle initial states, and build responsive fallbacks. The techniques here apply to any scroll-driven component, not just timelines.
Try implementing this in your own projects. Experiment with the timing values, colors, and animation sequences to match your brand. Let me know in the comments if you have questions, and subscribe for more practical development guides.