perkun.eu Services Portfolio Blog About Contact PL
← Blog

5/12/2025

GSAP ScrollTrigger — animations that don't kill Core Web Vitals

TL;DR: GSAP ScrollTrigger creates scroll animations, but naive implementation destroys LCP and CLS. Three rules: animate transform/opacity (not layout properties), lazy load GSAP, handle prefers-reduced-motion.

Entrance animations triggered on scroll are one of those effects that look great in demos and break PageSpeed in production. The culprit is usually not the GSAP library itself — it’s how you implement it. GSAP is one of the fastest animation libraries available, but only if you know what to avoid.

Why GSAP performs while other animations don’t

GSAP uses requestAnimationFrame internally and synchronizes all animations in a single callback per frame. The key insight: if you animate only transform and opacity, the browser can execute those animations in a separate compositor thread — without involving the main JavaScript thread. Zero layout thrashing. The animation runs smoothly even when the main thread is busy.

Compare this to: animating width, height, margin, padding, top, or left triggers a full reflow — the browser must recalculate positions of all elements on the page. At 60 fps that’s 16ms per frame for an operation that may take 5–10ms. Result: dropped frames and jank.

Core Web Vitals and animations

CLS (Cumulative Layout Shift) — don’t animate layout properties. Any animation of width, margin, or position that changes the space an element occupies in the document generates layout shift measured by CLS. Instead of {width: 0, width: 200} use {scaleX: 0, scaleX: 1} — the same visual effect, zero CLS.

LCP (Largest Contentful Paint) — hiding a hero element with opacity: 0 and an entrance animation is a classic trap. The element exists in the DOM, is rendered by the browser, but the user can’t see it. Google measures LCP as the moment when the element appears in the viewport — and counts the DOM moment, not the animation. Result: an LCP penalty with no UX benefit. Solution: animate elements below the fold, let the hero section be visible immediately.

Correct ScrollTrigger usage

import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

// Good: opacity + transform, scrub for smoothness
gsap.fromTo('.card',
  { opacity: 0, y: 40 },
  {
    opacity: 1,
    y: 0,
    duration: 0.6,
    stagger: 0.1,
    scrollTrigger: {
      trigger: '.cards-section',
      start: 'top 80%',
      end: 'bottom 20%',
      scrub: false,  // trigger on entry, not scrub
      // markers: true,  // enable during debugging
    }
  }
);

scrub: true makes the animation follow scroll position — the user scrolls, the animation moves proportionally. This is great for parallax effects, but not for element entrances (there you want a trigger, not scrub).

prefers-reduced-motion

Some users have prefers-reduced-motion: reduce enabled due to vestibular disorders, epilepsy, or personal preference. Ignoring this setting is both an accessibility and SEO problem.

GSAP has a built-in matchMedia():

const mm = gsap.matchMedia();

mm.add('(prefers-reduced-motion: no-preference)', () => {
  // Full animations
  gsap.fromTo('.hero-text', { opacity: 0, y: 30 }, {
    opacity: 1, y: 0, duration: 0.8,
    scrollTrigger: { trigger: '.hero', start: 'top 70%' }
  });
});

mm.add('(prefers-reduced-motion: reduce)', () => {
  // No animations — elements visible immediately
  gsap.set('.hero-text', { opacity: 1, y: 0 });
});

Lazy loading GSAP

GSAP is ~80kb minified. For a page where animations are only in one section, loading GSAP as part of the main bundle is an unnecessary cost for LCP.

// Load GSAP only when the section is close to the viewport
const observer = new IntersectionObserver(async (entries) => {
  if (entries[0].isIntersecting) {
    const { gsap } = await import('gsap');
    const { ScrollTrigger } = await import('gsap/ScrollTrigger');
    gsap.registerPlugin(ScrollTrigger);
    
    // initialize animations
    initAnimations(gsap, ScrollTrigger);
    observer.disconnect();
  }
}, { rootMargin: '200px' }); // load 200px before entering viewport

observer.observe(document.querySelector('.animated-section'));

rootMargin: '200px' provides a buffer — GSAP will load before the user reaches the section, so the animation won’t “pop in” after loading.

Summary

GSAP ScrollTrigger and good Core Web Vitals are not mutually exclusive — they require a conscious approach. Animate only transform and opacity, never layout properties. Don’t hide above-the-fold elements with entrance animations. Handle prefers-reduced-motion. Consider lazy loading if animations are in the lower part of the page. These four rules are enough to have both impressive animations and green PageSpeed scores.