GSAP ScrollTrigger Patterns I Use in Every Project
Scroll-linked reveals, parallax, pinned sections, and the performance tricks that keep everything at 60fps.
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:
- Only animate
transformandopacity. These are compositor-only properties. They don't trigger layout or paint. The moment you animatewidth,height,top, orleft, you're causing layout thrashing. will-change: transformon animated elements. This promotes the element to its own compositor layer. Apply it via CSS before the animation starts, not during.- 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
}
- 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())
})
- 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.