Scroll Animations
12 minScroll Animations
Scroll-driven animation ties motion to the user's scroll position instead of a timer. The element doesn't move for 300ms — it moves as far as the user has scrolled. This creates a direct-manipulation feel that time-based animation can't replicate, and when used with restraint, it makes content feel physically connected to the viewport.
The key word is restraint. Scroll-driven animation is one of the most abused techniques on the web. Parallax effects that break spatial logic, text that only appears once you've scrolled to it (hiding content behind a scroll gate), and full-page takeovers that hijack the scroll bar have given the technique a bad reputation. The principles from the "When to Animate" lesson apply doubly here: if the scroll animation doesn't serve orientation, feedback, or continuity, remove it.
CSS scroll-driven animations
Modern CSS provides two timeline types that replace JavaScript scroll listeners for most use cases:
Scroll progress timeline
animation-timeline: scroll() ties an existing CSS keyframe animation to the scroll position of a container. The animation plays from 0% at the top to 100% at the bottom:
@keyframes fade-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.hero-title {
animation: fade-in linear both;
animation-timeline: scroll();
animation-range: 0% 30%;
}
animation-range controls which portion of the scroll distance drives the animation. Without it, the animation spans the entire scroll height — usually too slow. 0% 30% means the animation completes by the time the user has scrolled 30% of the container.
View progress timeline
animation-timeline: view() ties the animation to an element's intersection with the viewport — similar to IntersectionObserver but declarative:
.card {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
entry 0% is when the element first touches the viewport edge; entry 100% is when it's fully visible. The animation plays across that range. This is the modern replacement for "fade in on scroll" patterns that previously required JavaScript.
IntersectionObserver patterns
For browsers without animation-timeline support, or when you need class-based triggering rather than continuous scrubbing, IntersectionObserver is the standard approach:
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
}
},
{ threshold: 0.2 }
);
document.querySelectorAll('[data-animate]').forEach((el) => {
observer.observe(el);
});
Pair with a CSS class that triggers the animation:
[data-animate] {
opacity: 0;
transform: translateY(16px);
transition: opacity 400ms ease-out, transform 400ms ease-out;
}
[data-animate].visible {
opacity: 1;
transform: translateY(0);
}
The unobserve call is important: once an element has animated in, stop watching it. Reversing entrance animations on scroll-back feels jarring — the user is navigating, not rewinding a timeline.
Parallax
Parallax moves background elements at a different rate than foreground elements, creating an illusion of depth. On the web, this typically means a background image scrolling at 50% of the page scroll speed.
The simplest CSS approach:
.parallax-container {
perspective: 1px;
overflow-y: auto;
height: 100vh;
}
.parallax-bg {
transform: translateZ(-1px) scale(2);
}
This uses CSS 3D transforms to create real depth-based parallax without JavaScript. The scale(2) compensates for the apparent size reduction from the Z-axis offset.
When to use parallax: Hero sections, landing pages, storytelling layouts — places where a sense of depth adds to the narrative. When not to: Content-heavy pages, documentation, dashboards, mobile. Parallax on mobile is almost always a mistake: touch scrolling doesn't produce the smooth, continuous position updates that parallax needs, and the computational overhead causes dropped frames.
Performance
Scroll-driven animations fire on every frame during scroll. Performance rules are non-negotiable:
Only animate transform and opacity. Animating top, left, width, height, margin, or padding on scroll causes layout recalculation every frame. The result is visible jank even on fast hardware.
Use will-change: transform sparingly. It promotes the element to its own compositing layer, which helps animation performance but costs memory. Apply it only to elements that are actively scrolling, and remove it when the animation completes.
Debounce JavaScript scroll handlers. If you must use addEventListener('scroll', ...) instead of IntersectionObserver, use requestAnimationFrame to batch updates to one per frame:
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
updateParallax();
ticking = false;
});
ticking = true;
}
});
Pause off-screen. Use IntersectionObserver to detect when scroll-animated elements leave the viewport, and pause their animations. Animating elements the user can't see wastes GPU cycles for nothing.
Accessibility
Scroll-driven animations must respect prefers-reduced-motion. Users who've enabled reduced motion are often doing so because motion causes physical discomfort — and scroll-linked motion, which they can't control or dismiss, is particularly problematic:
@media (prefers-reduced-motion: reduce) {
.parallax-bg {
transform: none;
}
[data-animate] {
opacity: 1;
transform: none;
transition: none;
}
* {
animation-timeline: initial !important;
}
}
When reduced motion is active, show the final state of every scroll animation immediately. Don't reduce the animation — remove it.
Common mistakes
Scroll-jacking. Hijacking the native scroll to create a custom scrolling experience. This breaks browser back/forward, bookmark positions, find-on-page, screen readers, and every user's muscle memory. Never do it.
Hiding content behind scroll. If text is invisible until the user scrolls to it, search engines can't index it and screen readers can't read it. Entrance animations should reveal content that's already in the DOM with opacity: 0, not content that's dynamically injected.
Reversing on scroll-back. An element that fades in as you scroll down should stay visible when you scroll back up. Re-hiding it punishes users for scrolling backwards and makes the page feel unreliable.
Animating during momentum scroll on mobile. iOS momentum scrolling fires scroll events at irregular intervals. Complex scroll-driven animations stutter visibly. Keep mobile scroll animations to simple opacity/transform transitions triggered by IntersectionObserver, not continuous scrubbing.