DEV Community

rashid ks
rashid ks

Posted on

Building Performant Scroll-Driven Animations with GSAP ScrollTrigger

Scroll-driven animation looks great in a portfolio site, and it's also one of the easiest places to accidentally tank your Core Web Vitals if you build it wrong. Here's the actual pattern I use to get smooth scroll effects without the jank — and the specific mistakes that cause it.

The one rule that matters more than any other

Only animate transform and opacity. Never animate width, height, top, left, margin, or box-shadow directly in a scroll-driven animation. Those properties trigger layout recalculation on every scroll tick, and at 60fps that recalculation is what causes visible stutter on anything but a high-end device.

// Bad — triggers layout on every scroll frame
gsap.to(".card", {
  scrollTrigger: { trigger: ".card", scrub: true },
  width: "300px",
  top: "50px"
});

// Good — GPU-accelerated, no layout thrashing
gsap.to(".card", {
  scrollTrigger: { trigger: ".card", scrub: true },
  scale: 1.2,
  y: 50
});
Enter fullscreen mode Exit fullscreen mode

Basic scrub animation

scrub: true ties the animation's progress directly to scroll position instead of playing on a timer — this is what makes it feel "attached" to the scrollbar rather than just triggered once.

gsap.registerPlugin(ScrollTrigger);

gsap.to(".hero-image", {
  scrollTrigger: {
    trigger: ".hero-section",
    start: "top top",
    end: "bottom top",
    scrub: 1 // smoothing — higher = more lag/smoothness
  },
  scale: 1.3,
  opacity: 0.4
});
Enter fullscreen mode Exit fullscreen mode

The scrub: 1 (instead of scrub: true) adds a small smoothing delay between scroll position and animation progress — it's the difference between animation that feels mechanically tied to the scrollbar versus one that feels like it has actual weight to it.

Pinning a section

Pinning locks an element in place while the user keeps scrolling — the classic "text changes while the image stays fixed" effect:

ScrollTrigger.create({
  trigger: ".pin-section",
  start: "top top",
  end: "+=800", // pin for 800px of scroll
  pin: true,
  pinSpacing: true // keeps document flow correct after unpin
});
Enter fullscreen mode Exit fullscreen mode

The most common bug here is forgetting pinSpacing, which leaves a layout gap or overlap once the pin releases — easy to miss in development since it often only shows up after content below the pinned section is added.

Batching triggers for content-visibility gains

If you have many similar elements (a grid of cards animating in on scroll), don't create one ScrollTrigger per element — use ScrollTrigger.batch() instead. It groups elements entering the viewport together instead of running individual calculations for each one:

ScrollTrigger.batch(".fade-in-card", {
  onEnter: batch => gsap.to(batch, {
    opacity: 1,
    y: 0,
    stagger: 0.15,
    overwrite: true
  }),
  start: "top 85%"
});
Enter fullscreen mode Exit fullscreen mode

Killing triggers on route change or resize

If this is part of an SPA or any dynamically re-rendered layout, stale ScrollTriggers pointing at removed DOM nodes are a real memory leak and a real source of "why is this animation firing on the wrong element" bugs:

function cleanupScrollTriggers() {
  ScrollTrigger.getAll().forEach(trigger => trigger.kill());
}
Enter fullscreen mode Exit fullscreen mode

Call this before re-initializing animations on route/content change, then re-run your setup function afterward.

The performance check that actually matters

Chrome DevTools → Performance tab → record a scroll pass. Look at the Rendering section specifically for Layout Shift and Paint events firing during scroll — if you see repeated layout/paint blocks stacked up during the scrub, something in your animation is touching a non-GPU-accelerated property, even if it's not obvious from the code which one.


I build this kind of animated, performance-conscious front-end work for client sites — more at capareach.com.

Top comments (0)