Build a Scroll-Driven Timeline with GSAP ScrollTrigger

Step-by-step React guide to a pinned, production-ready GSAP ScrollTrigger timeline with pixel-perfect positioning and…

·Updated on:·Matija Žiberna·
Build a Scroll-Driven Timeline with GSAP ScrollTrigger

⚛️ Advanced React Development Guides

Comprehensive React guides covering hooks, performance optimization, and React 19 features. Includes code examples and prompts to boost your workflow.

No spam. Unsubscribe anytime.

How to Build a Scroll-Driven Timeline with GSAP ScrollTrigger and React

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.

// File: src/components/blocks/process/process-template-2/index.tsx
'use client';

import { useRef, useLayoutEffect } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { BlockHeader } from '@/components/shared';
import { SectionWrapper } from '@/components/shared/section-wrapper';
import type { ProcessBlock } from '@/types/blocks';
import { getBlockOverrideClasses } from '@/lib/block-overrides';
import { cn } from '@/lib/utils';

// Register ScrollTrigger plugin
if (typeof window !== 'undefined') {
  gsap.registerPlugin(ScrollTrigger);
}

interface ProcessTemplate2Props {
  data: ProcessBlock;
}

export function ProcessTemplate2({ data }: ProcessTemplate2Props) {
  const {
    tagline,
    title,
    steps = [],
    bgColor = 'white',
  } = data;

  const containerRef = useRef<HTMLDivElement>(null);
  const timelineRef = useRef<HTMLDivElement>(null);
  const backgroundLineRef = useRef<HTMLDivElement>(null);

  const { wrapperClass, kickerClass, titleClass } = getBlockOverrideClasses(data);

  // Animation logic will go here

  return (
    // JSX will go here
  );
}

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.

// File: src/components/blocks/process/process-template-2/index.tsx
useLayoutEffect(() => {
  if (!containerRef.current || !timelineRef.current) return;

  const ctx = gsap.context(() => {
    // Calculate and set background line height from first to last dot
    if (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",
      ) as HTMLElement;

      if (firstDot && lastDot && timelineContainer) {
        const firstDotRect = firstDot.getBoundingClientRect();
        const lastDotRect = lastDot.getBoundingClientRect();
        const timelineRect = timelineContainer.getBoundingClientRect();

        // Calculate distance between center of first and last dot
        const lineHeight =
          lastDotRect.top +
          lastDotRect.height / 2 -
          (firstDotRect.top + firstDotRect.height / 2);

        // Calculate top offset relative to the timeline container
        const 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.

// File: src/components/blocks/process/process-template-2/index.tsx (continuing within gsap.context)
const stepsElements = steps.map((_, i) => ({
  dot: containerRef.current?.querySelector(`[data-step="${i}"] .timeline-dot`),
  dotInner: containerRef.current?.querySelector(
    `[data-step="${i}"] .timeline-dot-inner`,
  ),
  card: containerRef.current?.querySelector(
    `[data-step="${i}"] .timeline-card`,
  ),
  date: containerRef.current?.querySelector(
    `[data-step="${i}"] .timeline-date`,
  ),
}));

// Set initial state: all cards, dates, and dots invisible
stepsElements.forEach(({ card, date, dot }) => {
  if (card) {
    gsap.set(card, { opacity: 0, y: 20 });
  }
  if (date) {
    gsap.set(date, { opacity: 0, y: 20 });
  }
  if (dot) {
    gsap.set(dot, { opacity: 0, scale: 0 });
  }
});

// Set initial state: background line starts collapsed
if (backgroundLineRef.current) {
  gsap.set(backgroundLineRef.current, { scaleY: 0, transformOrigin: "top" });
}

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.

// File: src/components/blocks/process/process-template-2/index.tsx (continuing)
const totalSteps = steps.length;
const scrollDistance = totalSteps * 1800;

const masterTl = gsap.timeline({
  scrollTrigger: {
    trigger: containerRef.current,
    start: "top top",
    end: `+=${scrollDistance}`,
    pin: true,
    scrub: 0.5,
  },
});

// Fade out the header after some scrolling
const header = containerRef.current?.querySelector("header");
if (header) {
  masterTl.to(
    header,
    {
      opacity: 0,
      y: -20,
      duration: 1.5,
    },
    0,
  );
}

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.

// 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 fill
  if (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 effect
  const 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.

// File: src/components/blocks/process/process-template-2/index.tsx (continuing)
const progressLine = containerRef.current?.querySelector(
  ".timeline-progress-line",
) as HTMLElement;
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 height
  const targetHeight = (realIndex / (totalSteps - 1)) * maxHeight;

  // Animate line to the next dot
  if (progressLine && maxHeight > 0) {
    masterTl.to(progressLine, {
      height: `${targetHeight}px`,
      duration: 2,
      ease: "none",
    });
  }

  // Fade in the card and date
  if (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 dot
  if (dot) {
    masterTl.to(
      dot,
      {
        opacity: 1,
        scale: 1,
        duration: 0.4,
      },
      "-=0.4",
    );
  }

  // Animate the dot border expansion
  if (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 change
  if (card) {
    masterTl.to(
      card,
      {
        backgroundColor: "#f8f9fa",
        scale: 1.05,
        duration: 0.8,
      },
      "<",
    );
  }

  // Animate border fill effect
  const 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 color
  if (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.

// File: src/components/blocks/process/process-template-2/index.tsx (continuing)
// Reveal CTA button at the end
const 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 viewport
const 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.

// File: src/components/blocks/process/process-template-2/index.tsx
const renderCard = (step: ProcessBlock['steps'][number]) => {
  return (
    <div
      className="timeline-card relative border rounded-lg p-6 bg-white max-w-md w-full transition-all duration-300 origin-center"
      style={{ borderColor: '#f4f4f4' }}
    >
      <div
        className="card-border absolute inset-0 rounded-lg pointer-events-none opacity-0"
        style={{
          '--border-progress': '0%',
          '--border-width': '0px',
          background: `
            linear-gradient(to right, #003882 var(--border-progress), transparent var(--border-progress)) top / 100% var(--border-width) no-repeat,
            linear-gradient(to bottom, #003882 var(--border-progress), transparent var(--border-progress)) right / var(--border-width) 100% no-repeat,
            linear-gradient(to left, #003882 var(--border-progress), transparent var(--border-progress)) bottom / 100% var(--border-width) no-repeat,
            linear-gradient(to top, #003882 var(--border-progress), transparent var(--border-progress)) left / var(--border-width) 100% no-repeat
          `,
        } as React.CSSProperties}
      />

      {step.icon && (
        <div className="w-14 h-14 mb-4">
          <img src={step.icon} alt="" className="w-full h-full object-cover" />
        </div>
      )}

      {step.eventTitle && (
        <p className="text-base font-semibold text-[#000000] mb-2">
          {step.eventTitle}
        </p>
      )}

      {step.description && (
        <p className="text-base text-[#25272a] leading-relaxed font-normal">
          {step.description}
        </p>
      )}
    </div>
  );
};

const renderDateLabel = (step: ProcessBlock['steps'][number]) => {
  if (!step.title) return null;

  return (
    <p className="timeline-date text-sm font-medium text-[#000000] text-center min-w-[120px] transition-all duration-300 origin-center">
      {step.title}
    </p>
  );
};

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.

// File: src/components/blocks/process/process-template-2/index.tsx
return (
  <SectionWrapper
    ref={containerRef}
    className={wrapperClass}
    ariaLabel="process-timeline-heading"
  >
    <SectionWrapper.Content>
      <BlockHeader align="left" size="md" headingLevel="h2" headingId="process-timeline-heading" className="mb-20 max-w-3xl">
        {tagline && (
          <BlockHeader.Kicker className={cn("text-[#99afcd]", kickerClass)}>
            {tagline}
          </BlockHeader.Kicker>
        )}
        {title && (
          <BlockHeader.Title className={cn("text-[#003882]", titleClass)}>
            {title}
          </BlockHeader.Title>
        )}
      </BlockHeader>

      <div ref={timelineRef} className="hidden md:block max-w-5xl mx-auto">
        <div className="relative flex flex-col">
          <div
            ref={backgroundLineRef}
            aria-hidden
            className="absolute left-1/2 -translate-x-1/2 w-[5px] bg-[#eceded]"
          />
          <div
            aria-hidden
            className="timeline-progress-line absolute left-1/2 -translate-x-1/2 w-[5px] bg-linear-to-b from-[#008ccc] to-[#003882] h-0"
          />

          {steps.map((step, idx) => {
            const isEven = idx % 2 === 0;

            return (
              <div
                key={step.id || idx}
                data-step={idx}
                className="grid grid-cols-[1fr_auto_1fr] gap-20 items-center py-12"
              >
                <div className={`flex ${isEven ? 'justify-end' : 'justify-start'}`}>
                  {isEven ? renderCard(step) : renderDateLabel(step)}
                </div>

                <div className="relative flex flex-col items-center justify-center h-full z-10">
                  <div className="timeline-dot w-5 h-5 rounded-full transition-all duration-300 z-20 bg-white border-2 border-[#e6ebf3] flex items-center justify-center">
                    <div
                      className="timeline-dot-inner w-full h-full rounded-full transition-all duration-300 scale-0"
                      style={{ backgroundColor: 'transparent' }}
                    />
                  </div>
                </div>

                <div className={`flex ${isEven ? 'justify-start' : 'justify-end'}`}>
                  {isEven ? renderDateLabel(step) : renderCard(step)}
                </div>
              </div>
            );
          })}

          <div className="flex justify-center pt-12 pb-8">
            <button className="timeline-cta opacity-0 translate-y-4 bg-[#008ccc] text-white px-8 py-3 rounded-full font-semibold hover:bg-[#0077ad] transition-colors shadow-lg">
              Get Started
            </button>
          </div>
        </div>
      </div>

      <div className="md:hidden space-y-8">
        {steps.map((step, idx) => (
          <div key={step.id || idx} className="flex gap-4">
            <div className="flex flex-col items-center shrink-0">
              <div
                className="w-5 h-5 rounded-full shrink-0"
                style={{ backgroundColor: idx === 0 ? '#008ccc' : '#e6ebf3' }}
              />
              {idx < steps.length - 1 && (
                <div className="w-0.5 h-12 bg-[#eceded]" />
              )}
            </div>

            <div className="flex-1 flex flex-col gap-3">
              <p className="text-sm font-medium text-[#000000]">{step.title}</p>
              <div
                className="border rounded-lg p-4 bg-white"
                style={{ borderColor: idx === 0 ? '#cce8f5' : '#f4f4f4' }}
              >
                {step.icon && (
                  <div className="w-12 h-12 mb-3 shrink-0">
                    <img src={step.icon} alt="" className="w-full h-full object-cover" />
                  </div>
                )}
                {step.eventTitle && (
                  <p className="text-sm font-bold text-[#000000] mb-2">{step.eventTitle}</p>
                )}
                {step.description && (
                  <p className="text-sm text-[#25272a] leading-relaxed">{step.description}</p>
                )}
              </div>
            </div>
          </div>
        ))}
      </div>
    </SectionWrapper.Content>
  </SectionWrapper>
);

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.

Thanks, Matija

0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.