Design
Overview
Most scroll animations feel cheap. Here's the exact easing, timing, and trigger logic we use to make motion feel premium and intentional.
Sana Yildiz
The web is full of scroll animations that make me motion-sick. Elements fly in from nowhere, parallax layers jitter, and nothing feels connected to the user's scroll position.
The problem isn't GSAP. The problem is treating animation as decoration instead of communication.
GSAP scroll animation example
Good animation mimics the physical world. Scroll position becomes a slider that controls animation progress — not a trigger for abrupt changes.
// Bad: Linear easing feels robotic
gsap.to(element, {
scrollTrigger: {
scrub: true
},
x: 500
})
// Good: Power easing creates momentum
gsap.to(element, {
scrollTrigger: {
scrub: true,
ease: "power2.out" // Slow start, fast end
},
x: 500
})
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
// The professional baseline
const animation = gsap.from('.hero-title', {
scrollTrigger: {
trigger: '.hero',
start: 'top bottom', // When trigger's top hits bottom of viewport
end: 'top center', // When trigger's top hits center
scrub: 0.5, // 0.5 second lag for smoothness
markers: false, // Only enable during debugging
invalidateOnRefresh: true // Responsive-friendly
},
y: 100,
opacity: 0,
duration: 1,
ease: 'power2.out'
})
ScrollTrigger visualization
Elements fade in as they enter viewport:
gsap.utils.toArray('.reveal').forEach(element => {
gsap.from(element, {
scrollTrigger: {
trigger: element,
start: 'top 85%', // When element's top is 85% down viewport
toggleActions: 'play none none reverse'
},
y: 50,
opacity: 0,
duration: 0.8,
ease: 'power2.out'
})
})
// Background moves slower than foreground
gsap.to('.parallax-bg', {
scrollTrigger: {
trigger: '.section',
start: 'top bottom',
end: 'bottom top',
scrub: true
},
y: (i, target) => -target.offsetHeight * 0.2
})
// Foreground moves faster
gsap.to('.parallax-fg', {
scrollTrigger: {
trigger: '.section',
start: 'top bottom',
end: 'bottom top',
scrub: true
},
y: (i, target) => target.offsetHeight * 0.3
})
A common but often badly implemented pattern:
gsap.to('.progress-bar', {
scrollTrigger: {
trigger: document.body,
start: 'top top',
end: 'bottom bottom',
scrub: 0.3
},
scaleX: 1,
transformOrigin: '0% 50%',
ease: 'none'
})
Progress indicator animation
// Bad: All elements animate identically
gsap.from('.cards', {
scrollTrigger: {
trigger: '.cards-container',
start: 'top 80%'
},
scale: 0
})
// Good: Stagger with curve
gsap.from('.card', {
scrollTrigger: {
trigger: '.cards-container',
start: 'top 80%'
},
scale: 0,
opacity: 0,
stagger: {
each: 0.1,
from: 'center',
ease: 'power1.inOut'
},
duration: 0.6,
ease: 'back.out(0.7)'
})
ScrollTrigger.create({
trigger: '.story-section',
start: 'top top',
end: '+=300%',
pin: true,
snap: {
snapTo: [0, 0.33, 0.66, 1],
duration: 0.5,
ease: 'power1.inOut'
},
onUpdate: (self) => {
// Update chapter indicators
document.querySelector('.chapter').textContent =
Math.floor(self.progress * 4) + 1
}
})
✅ Use will-change for animating properties
✅ Animate transforms and opacity only (avoid layout properties)
✅ Batch animations when possible
✅ Kill ScrollTrigger on route changes
/* Tell browser to optimize */
.animated-element {
will-change: transform, opacity;
}
// Clean up to prevent memory leaks
useEffect(() => {
const animations = []
animations.push(gsap.from('.hero', { /* ... */ }))
return () => {
animations.forEach(anim => anim.kill())
ScrollTrigger.getAll().forEach(trigger => trigger.kill())
}
}, [])
Not everyone appreciates scroll animations:
// Respect prefers-reduced-motion
const shouldAnimate = window.matchMedia('(prefers-reduced-motion: no-preference)').matches
if (shouldAnimate) {
// Your scroll animations
gsap.from('.element', { /* ... */ })
} else {
// Just show everything statically
gsap.set('.element', { opacity: 1, y: 0 })
}
// Complete hero animation
const tl = gsap.timeline({
scrollTrigger: {
trigger: '.hero',
start: 'top top',
end: 'bottom top',
scrub: 0.5,
pin: true
}
})
tl.from('.hero-title', { scale: 1.2, opacity: 0, duration: 2 })
.from('.hero-subtitle', { y: 50, opacity: 0 }, '-=1.5')
.from('.hero-cta', { y: 30, opacity: 0 }, '-=1')
.to('.hero-overlay', { opacity: 0.8 }, 0)
❌ Animating top/left/width/height (causes layout recalc)
❌ Using scrollTo without easing
❌ Ignoring scrub values (0 gives abrupt changes)
❌ Creating 50 independent ScrollTriggers (use timelines)
❌ No cleanup on route changes
The best scroll animations are the ones users don't notice — they just feel right. That's the goal. Not "look what I can do," but "this experience is delightful."
Written by
Sana Yildiz
Keep Reading
Stop fighting Tailwind's utility classes. Learn how we compose tokens, variants, and component abstractions into a coherent design system.
Handoff between design and engineering breaks at scale. Here's the component naming, token structure, and review process we use to keep it seamless.
Let's work together