“How do I make things animate when I scroll down the page?”
It’s one of the most common questions in web development. You’ve seen it everywhere—text fading in as you scroll, images sliding into view, progress bars that fill as you read. Now you want to build it yourself.
The good news: there are several ways to do this, from pure CSS to full JavaScript libraries. The approach you choose depends on what you’re building and how much control you need.
This guide covers three methods, starting simple and building up. By the end, you’ll know which one fits your project.
What “Animate on Scroll” Actually Means
Before diving into code, let’s clarify what we’re building. “Scroll animation” typically means one of two things:
Scroll-triggered animations — An animation plays once when an element enters the viewport. The scroll triggers it, but the animation runs independently.
Scroll-linked animations — The animation progress is tied directly to scroll position. Scroll down, animation moves forward. Scroll up, it reverses. This is sometimes called “scrubbing.”
Most beginners start with scroll-triggered animations (the first type), so that’s where we’ll begin.
Approach 1: CSS Scroll-Driven Animations
CSS now has native scroll animation support. No JavaScript required. It uses the view() timeline to track an element’s visibility as you scroll.
Basic Fade-In on Scroll
.animate-on-scroll {
animation: fade-in auto linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
How It Works
animation-duration: autoensures the animation progress maps 1:1 with the scroll progress.animation-timeline: view()connects the animation to the element’s visibility in the viewport.animation-range: entry 0% cover 40%defines the “active zone.” The animation starts the moment the element enters the bottom of the screen (entry 0%) and completes by the time it reaches the 40% mark of the viewport.
Pro Tip: Delaying the Reveal Want the element to wait until it’s further up the screen before fading in? Just adjust the start of the range:
animation-range: entry 30% cover 50%;. This waits until the element is 30% into the viewport before starting the animation.
You can see these techniques in action in our Free CSS Scroll Reveal Demo.
Respecting User Preferences
Always respect users who prefer reduced motion:
@media (prefers-reduced-motion: reduce) {
.animate-on-scroll {
animation: none;
opacity: 1;
transform: none;
}
}
When CSS Scroll Animations Work Well
- Simple fade/slide entrance effects
- Projects targeting modern browsers only
- When you want zero JavaScript for animations
- Quick prototypes
The Limitations
Browser support — As of early 2026, CSS scroll-driven animations work in Chrome, Edge, and Safari 18+. Firefox support is still experimental. Check caniuse.com for current status.
Limited control — You can’t easily pause, reverse, or sequence multiple elements. There are no callbacks to run code when animations complete.
Container Quirks — If a parent container has overflow: hidden or auto, it can sometimes “trap” the scroll timeline, preventing the animation from firing on the main page scroll.
Always scroll-linked — CSS scroll animations tie progress to scroll position. If you want a traditional animation that simply triggers once and plays to completion independently, you’ll still need JavaScript.
Approach 2: Intersection Observer (Vanilla JavaScript)
Intersection Observer is a browser API that tells you when elements enter or leave the viewport. It’s well-supported and requires no libraries.
Basic Fade-In on Scroll
<div class="fade-in">This will fade in</div>
<div class="fade-in">So will this</div>
.fade-in {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.fade-in.is-visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.fade-in {
opacity: 1;
transform: none;
transition: none;
}
}
// Check for reduced motion preference
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReducedMotion) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
}
});
}, {
threshold: 0.1 // Trigger when 10% visible
});
document.querySelectorAll('.fade-in').forEach(el => {
observer.observe(el);
});
}
How It Works
- Elements start with
opacity: 0and are shifted down - CSS transitions are defined for smooth animation
- Intersection Observer watches for elements entering the viewport
- When an element is 10% visible, we add the
is-visibleclass - CSS transitions handle the actual animation
Triggering Once vs. Every Time
The example above triggers once. To re-trigger when scrolling back up:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
} else {
entry.target.classList.remove('is-visible'); // Reset when out of view
}
});
}, {
threshold: 0.1
});
When Intersection Observer Works Well
- Simple class-toggle animations
- Projects where you can’t add libraries
- When you need broad browser support (works everywhere)
- Lazy-loading images or content
The Limitations
No animation control — You’re toggling classes, not controlling animations. You can’t pause mid-animation, reverse smoothly, or scrub with scroll position.
Manual staggering — Want items to animate in sequence? You’ll need to calculate delays yourself:
document.querySelectorAll('.fade-in').forEach((el, index) => {
el.style.transitionDelay = `${index * 0.1}s`;
observer.observe(el);
});
This works but gets complicated with dynamic content or responsive layouts.
No scroll-linking — Intersection Observer tells you if something is visible, not how much. You can’t tie animation progress to scroll position without significant extra code.
Cleanup burden — In single-page apps or dynamic content, you need to manually unobserve() elements to prevent memory leaks.
Approach 3: GSAP ScrollTrigger
GSAP is a JavaScript animation library. ScrollTrigger is its plugin for scroll-based animations. Together, they handle everything from simple triggers to complex scroll-linked sequences.
Basic Fade-In on Scroll
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
<div class="fade-in">This will fade in</div>
<div class="fade-in">So will this</div>
// Check for reduced motion preference
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
gsap.registerPlugin(ScrollTrigger);
gsap.from('.fade-in', {
y: 30,
opacity: 0,
duration: 0.6,
ease: 'power2.out',
stagger: 0.1, // Each element animates 0.1s after the previous
scrollTrigger: {
trigger: '.fade-in',
start: 'top 85%', // Animation starts when top of element hits 85% down the viewport
}
});
}
This achieves the same result as the previous approaches, with one key difference: the stagger: 0.1 line automatically sequences multiple elements. No manual delay calculation. To see this stagger pattern in a production context, the Stagger Grid Reveal and CSS Scroll Reveal effects both demonstrate entrance animations at scale.
Scroll-Linked Animation (Scrubbing)
Here’s where ScrollTrigger shows its strength. Let’s make a progress bar that fills as you scroll through an article:
<div class="progress-bar"></div>
<article class="content">
<!-- Long content here -->
</article>
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
width: 0%;
background: #c8ff00;
z-index: 100;
}
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
gsap.registerPlugin(ScrollTrigger);
gsap.to('.progress-bar', {
width: '100%',
ease: 'none',
scrollTrigger: {
trigger: '.content',
start: 'top top',
end: 'bottom bottom',
scrub: true // Animation progress = scroll progress
}
});
}
The scrub: true option links animation progress directly to scroll position. Scroll down, the bar grows. Scroll up, it shrinks. This is scroll-linked animation.
For a more advanced example of scroll-linked animation, the Scroll Image Sequence effect renders a canvas-based frame sequence tied to scroll position, similar to how Apple presents product reveals on their marketing pages.
Pinning Elements
Want an element to “stick” while you scroll through a section? That’s pinning:
gsap.to('.sticky-image', {
scale: 1.2,
scrollTrigger: {
trigger: '.image-section',
start: 'top top',
end: 'bottom top',
scrub: true,
pin: true // Element stays fixed while scrolling through the section
}
});
The image stays pinned in place and scales up as you scroll through .image-section. The Scroll Hijack Sections effect uses this pinning technique to create full-screen section transitions controlled entirely by scroll position.
Visual Debugging
One of ScrollTrigger’s most useful features for beginners:
scrollTrigger: {
trigger: '.fade-in',
start: 'top 85%',
markers: true // Shows start/end markers in the browser
}
This displays colored lines showing exactly where your triggers start and end. Invaluable when animations aren’t firing when expected.
Responsive Animations
Different animations for different screen sizes:
let mm = gsap.matchMedia();
mm.add('(min-width: 768px)', () => {
// Desktop: horizontal scroll gallery
gsap.to('.gallery', {
x: () => -galleryWidth,
scrollTrigger: { scrub: true, pin: true }
});
});
mm.add('(max-width: 767px)', () => {
// Mobile: simple fade-in
gsap.from('.gallery-item', {
opacity: 0,
y: 30,
stagger: 0.1,
scrollTrigger: { trigger: '.gallery' }
});
});
ScrollTrigger properly creates and destroys animations at breakpoints, handling cleanup automatically.
When ScrollTrigger Works Well
- Any scroll animation beyond simple fades
- Scroll-linked (scrubbed) animations
- Pinning elements while scrolling
- Sequencing multiple elements
- When you need callbacks (onEnter, onLeave, etc.)
- Production sites where cross-browser consistency matters
- Complex responsive behavior
The Trade-offs
Library dependency — GSAP core is ~60kb minified, ScrollTrigger adds ~25kb. For many projects this is negligible, but if you’re optimizing every kilobyte, it’s a consideration.
Learning curve — More options means more to learn. Though for basic triggers, the API is straightforward.
Overkill for simple cases — If you genuinely only need one element to fade in, Intersection Observer or CSS might be simpler. For a deeper comparison of when to use CSS versus GSAP, see GSAP vs CSS Animations: A Practical Guide.
Quick Comparison
| Feature | CSS Scroll | Intersection Observer | ScrollTrigger |
|---|---|---|---|
| Scroll-triggered | ✓ (sort of) | ✓ | ✓ |
| Scroll-linked (scrub) | ✓ | ✗ | ✓ |
| Pinning | ✗ | ✗ | ✓ |
| Staggering | ✗ | Manual | Built-in |
| Callbacks | ✗ | Limited | Full |
| Responsive | CSS media queries | Manual | Built-in |
| Browser support | Modern only | Excellent | Excellent |
| Dependencies | None | None | ~85kb |
| Debug tools | Browser DevTools | None | Visual markers |
The Decision Framework
Use CSS scroll-driven animations when:
- You only need simple entrance effects
- You’re targeting modern browsers (Chrome, Edge, Safari 18+)
- Zero JavaScript is a requirement
- You’re prototyping quickly
Use Intersection Observer when:
- You need class toggles on scroll
- You can’t add any libraries
- Browser support must be universal
- You’re also using it for lazy-loading
Use GSAP ScrollTrigger when:
- You need scroll-linked animations (scrubbing)
- You need to pin elements
- You’re sequencing multiple animations
- You need callbacks to trigger other code
- You want visual debugging tools
- You need consistent behavior across browsers
- The project is a production site with complex animations
The hybrid approach: Many projects use multiple approaches. Intersection Observer for lazy-loading images, CSS transitions for hover states, and ScrollTrigger for the hero animation. There’s no rule saying you must pick one.
Getting Started with ScrollTrigger
If you want to try ScrollTrigger, here’s the minimal setup:
<!-- Add to your HTML -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
// Your first scroll animation
gsap.registerPlugin(ScrollTrigger);
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
gsap.from('.hero-title', {
y: 50,
opacity: 0,
duration: 1,
scrollTrigger: {
trigger: '.hero',
start: 'top 80%',
markers: true // Remove this once it's working
}
});
}
That’s genuinely all it takes. The GSAP documentation is thorough if you want to explore further.
Common Mistakes to Avoid
Regardless of which approach you choose, watch out for these:
Forgetting reduced motion — Always check prefers-reduced-motion. Some users experience motion sickness or have vestibular disorders. Respecting this preference isn’t optional.
Animating too many properties — Stick to transform and opacity for smooth performance. Animating width, height, top, left, or margin triggers layout recalculations and can cause jank.
Animations that block content — If users must wait for animations before they can read or interact, you’ve gone too far. Animations should enhance, not obstruct.
No fallback for failures — If JavaScript fails to load, elements with opacity: 0 stay invisible. This is especially important on platforms like WordPress where caching plugins can sometimes delay script loading. Consider CSS that shows content by default, then hides it when JS is ready:
.fade-in {
opacity: 1; /* Visible by default */
}
.js-ready .fade-in {
opacity: 0; /* Hidden only when JS confirms it's ready */
}
Conclusion
There’s no universally “correct” way to animate on scroll. CSS scroll-driven animations are elegant when browser support fits. Intersection Observer handles simple triggers without dependencies. ScrollTrigger provides the control needed for complex, production-grade scroll experiences.
Start with the simplest approach that meets your needs. If you find yourself fighting limitations—manually calculating staggers, wishing you could scrub, needing callbacks—that’s when you graduate to more capable tools.
The best scroll animation is one that enhances the experience, performs well, and respects user preferences. How you build it matters less than what it achieves.
Looking for production-ready scroll animations? Browse the GSAP Vault effects library for scroll-triggered reveals, parallax effects, and interactive components.
Related Articles
- Animation for Content Editors — New to animation? Start with the vocabulary and context for understanding what’s possible.
- GSAP vs CSS Animations: A Practical Guide — A broader look at when CSS is enough and when GSAP becomes essential.
- Adding a Text Scramble Effect to WordPress — A practical walkthrough of adding a ScrollTrigger-powered text effect to a WordPress site.
- Using AI to Customize GSAP Effects — Once you’ve chosen ScrollTrigger, use AI assistants to customize effects faster.
- Webflow Custom Animations with GSAP — See how to implement ScrollTrigger animations specifically in Webflow projects.