GSAP ScrollTrigger Examples: 10 Scroll Animations You Can Use Today
Ten production-ready GSAP ScrollTrigger patterns: fade reveals, parallax, text effects, SVG drawing, mask reveals, and flip animations. Copy-paste code for each.

ScrollTrigger is the reason GSAP stays relevant when every framework ships its own animation primitives. It's not just a scroll listener — it's a full choreography system. Scrub, pin, batch, snap, refresh — the API covers cases that would take hundreds of lines in raw JavaScript.
This post covers ten patterns you'll actually use. Not demos. Not proof-of-concept toys. Patterns that show up in client work, portfolio sites, and product pages regularly.
Each example includes the core code and a link to a pre-built version in Annnimate if you want the complete implementation with all variants.
Setup
Before anything works, you need ScrollTrigger registered:
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
If you're using a CDN:
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
Register the plugin once, at app startup. Registering it inside components or functions that run multiple times causes issues.
1. Element Reveal on Scroll
The most common ScrollTrigger pattern. Elements fade and slide into view as you scroll past them.
gsap.from('.card', {
scrollTrigger: {
trigger: '.card',
start: 'top 85%',
toggleActions: 'play none none none',
},
opacity: 0,
y: 40,
duration: 0.8,
ease: 'expo.out',
})
For multiple elements, use gsap.utils.toArray and stagger:
gsap.from(gsap.utils.toArray('.card'), {
scrollTrigger: {
trigger: '.cards-grid',
start: 'top 80%',
},
opacity: 0,
y: 40,
duration: 0.8,
ease: 'expo.out',
stagger: 0.1,
})
The start: "top 85%" means the animation fires when the top of the trigger element hits 85% down the viewport. Adjust this number to control how early or late the reveal happens.
For a production-ready version with six reveal variants (fade, slide, scale, clip path, blur fade, rotate), see the Element Reveal animation.
2. Text Reveal by Lines
Raw opacity fades on text feel lazy. Line-by-line reveals — where each line wipes in from below a mask — feel intentional.
The technique requires wrapping each line in a container that clips the overflow, then animating the text up from yPercent: 100:
import { SplitText } from 'gsap/SplitText'
gsap.registerPlugin(SplitText)
const split = SplitText.create('.headline', { type: 'lines', mask: 'lines' })
gsap.from(split.lines, {
scrollTrigger: {
trigger: '.headline',
start: 'top 80%',
},
yPercent: 100,
opacity: 0,
duration: 1,
ease: 'expo.out',
stagger: 0.08,
})
The mask: "lines" option in SplitText automatically wraps each line in a clipping container. Without it, you'd need to add wrapper divs manually.
For the complete implementation with scroll scrub mode and accessibility considerations, see Text Reveal.
3. Parallax Depth Effect
Parallax is the effect where background elements move slower than the page, creating an illusion of depth. Overused when done obviously, invisible when done subtly.
gsap.to('.background-layer', {
scrollTrigger: {
trigger: '.section',
start: 'top bottom',
end: 'bottom top',
scrub: true,
},
y: -100,
})
gsap.to('.foreground-element', {
scrollTrigger: {
trigger: '.section',
start: 'top bottom',
end: 'bottom top',
scrub: true,
},
y: -200,
})
scrub: true ties the animation progress directly to scroll position. scrub: 1 adds a 1-second lag so the animation trails slightly behind scroll — usually smoother-feeling.
The key to parallax that doesn't feel cheap: keep the movement range small. Backgrounds at -80px to -120px over a full viewport height. Foreground elements at -150px to -200px. Anything beyond that and it starts to look like a bad 2013 website.
See the Parallax animation for a full implementation with configurable speed layers.
4. Scroll-Scrubbed Mask Reveal
Instead of fading in, content appears through an expanding shape — a circle, rectangle, or custom clip path — directly tied to scroll progress.
gsap.from('.image-container', {
scrollTrigger: {
trigger: '.reveal-section',
start: 'top center',
end: 'bottom center',
scrub: 1,
},
clipPath: 'circle(0% at 50% 50%)',
ease: 'none',
})
// End state
gsap.to('.image-container', {
clipPath: 'circle(100% at 50% 50%)',
})
For a fromTo version that scrubs through the full transition:
gsap.fromTo(
'.image-container',
{ clipPath: 'circle(0% at 50% 50%)' },
{
clipPath: 'circle(75% at 50% 50%)',
ease: 'none',
scrollTrigger: {
trigger: '.reveal-section',
start: 'top center',
end: 'bottom top',
scrub: 1,
},
}
)
The Mask Reveal animation includes circle, oval, rectangle, blob, and custom clip path variants, plus image masks.
5. Pinned Section with Horizontal Scroll
Pin a section vertically while content scrolls horizontally. Common for feature walkthroughs and timeline layouts.
const container = document.querySelector('.horizontal-container')
const slides = gsap.utils.toArray('.slide')
gsap.to(slides, {
xPercent: -100 * (slides.length - 1),
ease: 'none',
scrollTrigger: {
trigger: container,
pin: true,
scrub: 1,
end: () => '+=' + container.offsetWidth,
},
})
pin: true locks the trigger element in place while the user scrolls. end: () => "+=" + container.offsetWidth dynamically calculates the scroll distance needed to traverse all slides. Using a function for end means it recalculates on resize.
One thing to watch: pinned sections add extra scroll height to the page. Make sure your layout accounts for this, especially when stacking multiple pinned sections.
6. SVG Path Drawing
Animate SVG strokes drawing themselves on scroll. Works with any path element — signatures, illustrations, UI decorations.
import { DrawSVGPlugin } from 'gsap/DrawSVGPlugin'
gsap.registerPlugin(DrawSVGPlugin)
gsap.from('.svg-path', {
scrollTrigger: {
trigger: '.svg-section',
start: 'top 70%',
end: 'bottom 30%',
scrub: 1,
},
drawSVG: '0%',
})
DrawSVG animates the stroke-dasharray and stroke-dashoffset properties. Your SVG path needs a stroke and no fill, or the effect won't be visible.
For paths that draw from a specific point rather than the beginning:
gsap.fromTo(
'.svg-path',
{ drawSVG: '50% 50%' },
{
drawSVG: '0% 100%',
scrollTrigger: {
/* ... */
},
}
)
This draws outward from the center simultaneously in both directions — useful for logo reveals and decorative dividers.
See SVG Draw Path for the complete implementation.
7. Background Color Transition Between Sections
Change background and text colors smoothly as the user scrolls from section to section. Useful for storytelling pages where each section has a distinct mood.
const sections = gsap.utils.toArray('.color-section')
sections.forEach((section) => {
const bg = section.dataset.bg
const color = section.dataset.color
ScrollTrigger.create({
trigger: section,
start: 'top center',
end: 'bottom center',
onEnter: () => gsap.to('body', { backgroundColor: bg, color: color, duration: 0.6 }),
onEnterBack: () => gsap.to('body', { backgroundColor: bg, color: color, duration: 0.6 }),
})
})
HTML:
<section class="color-section" data-bg="#0a0a0a" data-color="#fafafa">Dark section</section>
<section class="color-section" data-bg="#fafafa" data-color="#0a0a0a">Light section</section>
The onEnterBack callback handles the reverse — when scrolling back up through a section. Without it, colors only update on downward scroll.
The Background Color animation handles multiple zones with smooth transitions and optional text color changes.
8. Velocity-Based Distortion
Read scroll velocity and distort elements based on how fast the user is scrolling. Fast scroll = more distortion. Slow scroll = elements return to normal.
ScrollTrigger.create({
onUpdate: (self) => {
const velocity = self.getVelocity()
const clampedVelocity = gsap.utils.clamp(-1000, 1000, velocity)
const skew = gsap.utils.mapRange(-1000, 1000, -10, 10, clampedVelocity)
gsap.to('.distort-target', {
skewY: skew,
duration: 0.5,
ease: 'power3.out',
overwrite: true,
})
},
})
self.getVelocity() returns scroll velocity in pixels per second. gsap.utils.mapRange converts that to a skew degree. overwrite: true prevents animations from stacking when the user scrolls fast.
For clip path-based velocity distortion where elements clip rather than skew, see Velocity Clip.
9. Folding Text Reveal
Characters fold in 3D space as they reveal. Each character rotates on X or Y axis from a folded state to flat, creating a paper-fold effect.
import { SplitText } from 'gsap/SplitText'
gsap.registerPlugin(SplitText)
const split = SplitText.create('.fold-text', { type: 'chars' })
gsap.from(split.chars, {
scrollTrigger: {
trigger: '.fold-text',
start: 'top 80%',
},
rotationX: 90,
transformOrigin: '0% 50% -20px',
opacity: 0,
duration: 0.8,
ease: 'expo.out',
stagger: 0.03,
})
The transformOrigin: "0% 50% -20px" shifts the rotation point back in Z space, creating the folding illusion. Without the Z offset, characters rotate around their face rather than their edge.
Perspective on the parent element amplifies the 3D effect:
.fold-text {
perspective: 400px;
}
See Folding Text for the full implementation with configurable fold direction and stagger patterns.
10. GSAP Flip on Scroll
GSAP Flip records element positions before and after a state change, then animates between them. Combined with ScrollTrigger, elements can smoothly rearrange as you scroll.
import { Flip } from 'gsap/Flip'
gsap.registerPlugin(Flip)
ScrollTrigger.create({
trigger: '.flip-section',
start: 'top center',
onEnter: () => {
const state = Flip.getState('.flip-card')
// Change the layout (add class, move to different container, etc.)
document.querySelector('.target-container').appendChild(document.querySelector('.flip-card'))
Flip.from(state, {
duration: 0.8,
ease: 'expo.inOut',
})
},
})
Flip works by snapshotting computed positions and sizes before the DOM change, then playing the transition after. The key is calling Flip.getState() before any DOM manipulation.
For a scroll-scrubbed version where elements move between zones as you scroll, see Flip Zone. For staggered multi-element flip reveals, see Multi Flip.
ScrollTrigger Performance Tips
A few things that cause problems in production:
Refresh on resize. ScrollTrigger calculates positions once. If your layout shifts on resize, call ScrollTrigger.refresh() or use the built-in resize handling. Pinned sections in particular need this.
Avoid animating width and height. These trigger layout recalculation. Use scaleX/scaleY instead.
Use will-change: transform sparingly. It promotes elements to their own compositor layer. Useful on heavily animated elements, wasteful on everything else.
invalidateOnRefresh: true recalculates start/end values on ScrollTrigger refresh. Required for any calculation that depends on element size:
ScrollTrigger.create({
trigger: '.dynamic-section',
end: () => '+=' + document.querySelector('.dynamic-section').offsetHeight,
invalidateOnRefresh: true,
})
Batch for lists. If you're animating many elements (50+), use ScrollTrigger.batch() instead of creating individual ScrollTriggers:
ScrollTrigger.batch('.card', {
onEnter: (elements) => {
gsap.from(elements, {
opacity: 0,
y: 40,
stagger: 0.1,
duration: 0.8,
ease: 'expo.out',
})
},
})
What's Next
These ten patterns cover the majority of scroll animation work. The more complex implementations — scroll-linked 3D transformations, physics-based momentum, WebGL shader effects driven by scroll — build on the same ScrollTrigger foundation.
If you want pre-built, copy-paste versions of all the animations above plus 40+ others, Annnimate has them ready to drop into any project. HTML/CSS/JS and React formats, with live previews and customizable parameters.