Skip to content
➜cat blog/gsap-scrolltrigger-patterns.md

GSAP ScrollTrigger Patterns I Use in Every Project

Scroll-linked reveals, parallax, pinned sections, and the performance tricks that keep everything at 60fps.

9 min

I've been using GSAP since my first job in 2019. Back then, it was CSS animations for simple things and GSAP for everything else. Seven years later, my approach has narrowed: GSAP for scroll-linked animation, CSS for state transitions, and nothing else unless there's a specific reason.

ScrollTrigger is the plugin I reach for most. Here are the patterns I've built up across projects.

Pattern 1: Staggered Reveal on Scroll

The most common pattern. Elements fade in and slide up as they enter the viewport. This portfolio uses it on every page.

import { gsap } from 'gsap'

function revealOnScroll(elements: string) {
  gsap.utils.toArray<HTMLElement>(elements).forEach((el) => {
    gsap.from(el, {
      scrollTrigger: {
        trigger: el,
        start: 'top 85%',
        toggleActions: 'play none none none',
      },
      opacity: 0,
      y: 24,
      duration: 0.5,
      ease: 'power2.out',
    })
  })
}

The key decisions here: start: 'top 85%' triggers when the element's top crosses 85% of the viewport, early enough that the animation is visible but late enough that it feels intentional. toggleActions: 'play none none none' means it plays once and never reverses. Reversing reveal animations on scroll-up looks fidgety.

For staggered groups (like a grid of cards), I batch them:

gsap.utils.toArray<HTMLElement>('.card-grid .card').forEach((card, i) => {
  gsap.from(card, {
    scrollTrigger: {
      trigger: card.parentElement,
      start: 'top 80%',
    },
    opacity: 0,
    y: 20,
    duration: 0.4,
    delay: i * 0.08,
    ease: 'power2.out',
  })
})

The stagger delay (i * 0.08) is small enough to feel sequential without making users wait.

Pattern 2: Parallax Depth

Parallax is overused when it moves everything, and elegant when it moves almost nothing. I use it sparingly, usually just a background element or a floating decorative shape.

gsap.to('.hero-bg', {
  scrollTrigger: {
    trigger: '.hero',
    start: 'top top',
    end: 'bottom top',
    scrub: true,
  },
  y: 120,
  ease: 'none',
})

scrub: true ties the animation directly to scroll position. ease: 'none' keeps the movement linear. Easing on scrubbed animations feels laggy because the animation is always catching up to the scroll position.

The rule I follow: never parallax text. It damages readability and triggers layout recalculations. Only parallax decorative elements, and only move them on the Y axis.

Pattern 3: Pinned Sections

Pinning freezes an element while content scrolls past it. Useful for step-by-step walkthroughs, feature comparisons, or hero animations that play out as the user scrolls.

gsap.to('.progress-bar', {
  scrollTrigger: {
    trigger: '.steps-section',
    start: 'top top',
    end: 'bottom bottom',
    pin: '.steps-sidebar',
    scrub: true,
  },
  scaleX: 1,
})

The pitfall with pinning: it adds padding to compensate for the pinned element's height, which can break layouts. Always test with markers: true during development and verify the end position matches where you expect content to resume.

Pattern 4: Text Split Animations

For hero text or section headings, splitting text into characters or words and animating them individually creates a premium feel:

function animateHeading(selector: string) {
  const el = document.querySelector(selector)
  if (!el || !el.textContent) return

  const words = el.textContent.split(' ')
  el.innerHTML = words
    .map((word) => `<span class="inline-block overflow-hidden"><span class="inline-block">${word}</span></span>`)
    .join(' ')

  const innerSpans = el.querySelectorAll('span > span')

  gsap.from(innerSpans, {
    scrollTrigger: {
      trigger: el,
      start: 'top 85%',
    },
    y: '100%',
    duration: 0.6,
    stagger: 0.04,
    ease: 'power3.out',
  })
}

The double-span wrapper is essential. The outer span has overflow: hidden so the inner span slides up from below the text line. Without it, you just see text floating up from wherever.

Performance Non-Negotiables

Every animation I ship follows these rules:

  1. Only animate transform and opacity. These are compositor-only properties. They don't trigger layout or paint. The moment you animate width, height, top, or left, you're causing layout thrashing.
  2. will-change: transform on animated elements. This promotes the element to its own compositor layer. Apply it via CSS before the animation starts, not during.
  3. Respect prefers-reduced-motion. Always check and skip animations for users who've opted out:
const prefersReduced = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
).matches

if (prefersReduced) {
  gsap.set(elements, { opacity: 1, y: 0 })
  return
}
  1. Kill ScrollTriggers on route change. In SPAs, orphaned ScrollTriggers stack up and cause memory leaks. In Vue/Nuxt, clean up in onUnmounted:
onUnmounted(() => {
  ScrollTrigger.getAll().forEach((trigger) => trigger.kill())
})
  1. Test on throttled mobile. Open Chrome DevTools, throttle CPU to 4x, and scroll through the page. If it stutters, reduce the number of simultaneous animations or simplify the transforms.

When Not to Use ScrollTrigger

Not every scroll interaction needs GSAP. CSS scroll-driven animations (the animation-timeline property) now handle simple cases natively with zero JavaScript. For progress bars, fade-ins on scroll, and basic parallax, the CSS approach is lighter and performs identically.

I reach for ScrollTrigger when I need precise control over timing, complex sequences, or coordination between multiple elements. For everything else, CSS is enough.