Scroll-based animations have been a staple of modern web design for as long as I can remember — progress bars tracking reading position, elements fading in as you scroll, parallax backgrounds, sticky headers that transform on the fly. For years, the only way to pull these off was with JavaScript scroll event listeners or libraries like GSAP ScrollTrigger. And honestly? The cost was steep: main-thread contention, jank on low-end devices, and ballooning bundle sizes that tanked your Core Web Vitals.
In 2026, that trade-off is finally over.
The CSS Scroll-Driven Animations specification lets you bind any CSS animation to scroll progress or element visibility — entirely in CSS, running on the compositor thread, with zero JavaScript overhead. In this guide, I'll walk you through exactly how to use animation-timeline, scroll(), and view() to build production-ready scroll effects that are fast by default.
What Are CSS Scroll-Driven Animations?
CSS scroll-driven animations extend the existing @keyframes system you already know. Instead of an animation progressing over a duration measured in seconds, it progresses based on scroll position. The spec introduces two new timeline types:
- Scroll Progress Timeline — tracks how far a scroll container has been scrolled, from 0% (top) to 100% (bottom). Created with the
scroll()function. - View Progress Timeline — tracks an element's visibility as it enters, crosses, and exits a scrollport (the visible area of a scroll container). Created with the
view()function.
Both timeline types replace the default time-based document timeline with a scroll-based one. The critical performance advantage? These animations run on the compositor thread — the same thread that handles scroll rendering itself — completely bypassing the main thread where JavaScript, layout, and paint operations all compete for CPU time.
Why This Matters for Web Performance
Before we write any code, let's be clear about the performance implications. JavaScript-based scroll animations create three distinct problems:
- Main-thread blocking — Every scroll event listener callback runs on the main thread. If that thread is busy with layout, paint, or other JavaScript, your animation frames get dropped. Simple as that.
- INP degradation — A scroll handler that takes 50–100ms to execute delays the browser's ability to respond to clicks, taps, and keyboard input. This directly worsens your Interaction to Next Paint score.
- Bundle size — Popular libraries add 8–48KB of gzipped JavaScript. That's render-blocking weight that delays Time to Interactive.
CSS scroll-driven animations eliminate all three problems. They run off the main thread, add zero bytes to your JavaScript bundle, and have no impact on INP because the compositor handles them independently.
Chrome's own performance case study demonstrated that the CSS version remained perfectly smooth at 60fps even while the main thread was artificially blocked — while the JavaScript equivalent dropped to single-digit frame rates. That's not a marginal improvement; it's a completely different league.
Scroll Progress Timeline with scroll()
The scroll() function creates a timeline that maps 0% to the start of the scroll range and 100% to the end. This is ideal for effects that should track overall scroll position — reading progress bars, parallax backgrounds, or transforming headers.
Building a Reading Progress Bar
Here's the most common use case: a progress bar at the top of the page that fills as the user scrolls down.
<div class="progress-bar" aria-hidden="true"></div>
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: #2563eb;
transform-origin: left;
z-index: 1000;
/* Define the animation */
animation: grow-progress linear both;
/* Bind it to scroll progress of the document */
animation-timeline: scroll();
}
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
That's it. A fully functional, GPU-accelerated progress bar in eight lines of CSS. No JavaScript, no scroll listeners, no requestAnimationFrame loops. The scroll() function defaults to the nearest ancestor scroll container (in this case, the root element) and the block axis (vertical scrolling).
I remember the first time I saw this working in a browser — after years of wiring up scroll listeners and throttling callbacks, it felt almost too easy.
Targeting a Specific Scroll Container
The scroll() function accepts two optional arguments: the scroller and the axis.
/* Track a specific container's horizontal scroll */
animation-timeline: scroll(nearest inline);
/* Track the root document scroll (vertical, default) */
animation-timeline: scroll(root block);
/* Track the element's own scroll */
animation-timeline: scroll(self);
The scroller keywords are nearest (default — walks up the DOM to find the closest scrollable ancestor), root (the document viewport), and self (the element itself as a scroll container). The axis keywords are block (default), inline, x, and y.
View Progress Timeline with view()
So, this is where things get really interesting. The view() function creates a timeline that tracks an element's position relative to its scroll container's visible area. The animation starts when the element first becomes visible and ends when it fully exits.
Fade-In Elements on Scroll
The classic "reveal on scroll" effect — elements fade and slide up as they enter the viewport:
.reveal {
animation: fade-in-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
The animation-range: entry 0% entry 100% confines the animation to the entry phase only — the period from when the element's leading edge first appears at the bottom of the viewport until it's fully visible. Without this range, the animation would also play in reverse as the element exits. (Ask me how I know — spent a solid hour debugging that one.)
Understanding View Timeline Ranges
View timelines expose named ranges that let you target specific phases of an element's visibility:
cover— the full timeline, from first visible pixel to last visible pixelentry— from the element's leading edge entering the scrollport to the element being fully insideexit— from the element starting to leave to its trailing edge fully exitingcontain— the period where the element is fully contained within the scrollport (or the scrollport is fully contained within the element, if the element is taller)entry-crossing— from the leading edge crossing the entry edge to the trailing edge crossing itexit-crossing— from the leading edge crossing the exit edge to the trailing edge crossing it
You can combine ranges for more nuanced effects:
/* Animate only while element is entering */
animation-range: entry;
/* Start 25% into entry, end 75% into exit */
animation-range: entry 25% exit 75%;
/* Animate only while fully visible */
animation-range: contain;
Animate In and Out
You can create elements that animate in as they enter and animate out as they leave, using named ranges directly inside @keyframes:
@keyframes animate-in-out {
entry 0% { opacity: 0; transform: translateY(60px); }
entry 100% { opacity: 1; transform: translateY(0); }
exit 0% { opacity: 1; transform: translateY(0); }
exit 100% { opacity: 0; transform: translateY(-60px); }
}
.card {
animation: animate-in-out linear both;
animation-timeline: view();
}
This single animation definition handles both entry and exit. The browser interpolates between keyframes as the element scrolls through each phase — no JavaScript coordination required. Pretty elegant, right?
Named Timelines and timeline-scope
Anonymous timelines (scroll() and view()) work well for simple cases, but sometimes you need an animation on one element driven by the scroll position of a different element. Named timelines solve this.
/* The scroll container declares a named timeline */
.sidebar {
overflow-y: auto;
scroll-timeline: --sidebar-scroll block;
}
/* A completely separate element uses that timeline */
.sidebar-progress {
animation: fill linear both;
animation-timeline: --sidebar-scroll;
}
@keyframes fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
By default, named timelines are only accessible to descendants of the element that defines them. To make a timeline accessible across sibling subtrees, use timeline-scope on a shared ancestor:
/* Make the timeline visible to all descendants of body */
body {
timeline-scope: --sidebar-scroll;
}
This is particularly useful for dashboard layouts where a sidebar's scroll position should drive animations in the main content area, or for horizontal scroll galleries where thumbnails outside the gallery need to track its progress.
Scroll-Triggered Animations: The 2026 Addition
Chrome 145 (shipping mid-2026) introduces an important new capability: scroll-triggered animations. While scroll-driven animations progress with scroll (scrubbing through keyframes), scroll-triggered animations are standard time-based animations that start when a scroll threshold is crossed.
Think of it as the CSS replacement for using IntersectionObserver to add an .is-visible class:
.hero-title {
animation: typewriter-reveal 0.8s ease-out both;
animation-trigger: view();
animation-range: entry 25%;
}
@keyframes typewriter-reveal {
from {
clip-path: inset(0 100% 0 0);
opacity: 0;
}
to {
clip-path: inset(0 0 0 0);
opacity: 1;
}
}
The animation plays once when the element reaches 25% into its entry phase, then runs on a normal time-based timeline at the specified duration and easing. Perfect for one-shot entrance animations that shouldn't scrub with scroll.
Performance Optimization: What to Animate
Here's the thing a lot of people miss: CSS scroll-driven animations run on the compositor thread only when you animate compositor-friendly properties. Animate a property that requires layout or paint, and the entire animation gets bumped back to the main thread — defeating the whole purpose.
Compositor-Friendly Properties (Off-Main-Thread)
transform(translate, rotate, scale, skew)opacityfilter(blur, brightness, contrast, etc.)backdrop-filterclip-path(in most browsers)
Properties That Force Main-Thread Execution
width,height,top,left,margin,padding(trigger layout)background-color,border,box-shadow(trigger paint)font-size,line-height(trigger layout + paint)- Custom properties (CSS variables — always main thread)
And here's the critical gotcha: if any single keyframe in your animation touches a main-thread property, the entire animation runs on the main thread. Don't mix compositor-friendly and layout-triggering properties in the same @keyframes block. If you absolutely need both, split them into two separate animations on the same element.
Layer Promotion with will-change
For elements that will be animated, you can hint the browser to pre-create a compositor layer:
.animated-card {
will-change: transform, opacity;
}
Use this sparingly though. Each promoted layer eats GPU memory. Promoting dozens of elements at once can cause layer explosion — where the GPU memory overhead actually makes things worse. I've seen this happen on image-heavy landing pages where someone slapped will-change on every card. Apply it only to elements that are actively being animated, and consider removing it after animations complete via the animationend event.
Progressive Enhancement and Browser Support
As of April 2026, browser support is strong and growing:
- Chrome / Edge / Opera — Full support since version 115 (July 2023)
- Safari — Full support since version 18 (September 2024)
- Firefox — Behind a flag (
layout.css.scroll-driven-animations.enabled), expected to ship unflagged in 2026
With Chrome, Safari, and Edge covering roughly 90% of global browser usage, scroll-driven animations are production-ready. But you still need fallbacks for the remaining ~10%.
The Progressive Enhancement Pattern
/* Base state: element is visible, no animation */
.reveal {
opacity: 1;
transform: translateY(0);
}
/* Scroll-driven animation only where supported */
@supports (animation-timeline: view()) {
.reveal {
animation: fade-in-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
This pattern ensures that in unsupported browsers, elements are simply visible — no broken layout, no missing content. The animation is purely an enhancement.
Polyfill Option
If you need animation behavior in all browsers, the scroll-timeline polyfill provides JavaScript-based fallback support. Include it as a progressive enhancement:
<script>
if (!CSS.supports('animation-timeline', 'view()')) {
import('https://flackr.github.io/scroll-timeline/dist/scroll-timeline.js');
}
</script>
Fair warning: the polyfill runs on the main thread, so you won't get the same performance benefits as native support. It's a compatibility bridge, not a performance solution.
CSS Scroll-Driven Animations vs. GSAP ScrollTrigger
GSAP ScrollTrigger has been the industry standard for scroll-based animations. Here's how the two approaches stack up for performance-conscious developers:
| Criterion | CSS Scroll-Driven Animations | GSAP ScrollTrigger |
|---|---|---|
| Bundle size | 0 KB | ~48 KB gzipped |
| Main thread impact | None (compositor) | Moderate (reads layout per frame) |
| INP impact | Zero | Can degrade on complex pages |
| Complex sequencing | Limited (CSS only) | Excellent (labels, timelines, callbacks) |
| Pinning / scrub effects | Basic (with position: sticky) |
Advanced (native pin support) |
| Browser support | ~90% (no Firefox yet) | Universal |
| Debugging | DevTools Animations panel | GSAP DevTools plugin, visual markers |
My recommendation: Use CSS scroll-driven animations as your default for reveal effects, progress indicators, parallax, and simple scrub animations. Reach for GSAP only when you need complex choreography, pinning with dynamic content, or universal browser support including Firefox. And hey, you can even combine both — CSS for simple effects and GSAP for complex sequences on the same page. There's no rule that says you have to pick one.
Real-World Patterns and Recipes
Let's get practical. Here are a few copy-paste-ready patterns I use regularly.
Parallax Background
.hero {
position: relative;
height: 100vh;
overflow: hidden;
}
.hero-bg {
position: absolute;
inset: -20% 0;
background: url('/hero.avif') center/cover;
animation: parallax linear both;
animation-timeline: scroll();
}
@keyframes parallax {
from { transform: translateY(0); }
to { transform: translateY(-15%); }
}
Sticky Header That Shrinks on Scroll
.site-header {
position: sticky;
top: 0;
animation: shrink-header linear both;
animation-timeline: scroll();
animation-range: 0px 200px;
}
@keyframes shrink-header {
from {
padding-block: 1.5rem;
background: transparent;
}
to {
padding-block: 0.5rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
}
}
Worth noting: Animating padding triggers layout on the main thread. For a fully compositor-driven version, use transform: scaleY() on a wrapper element instead and adjust inner elements with counter-transforms. A bit more work, but the smoothness difference on slower devices is noticeable.
Image Gallery Reveal
.gallery-item {
animation: gallery-enter linear both;
animation-timeline: view();
animation-range: entry 10% entry 90%;
}
@keyframes gallery-enter {
from {
opacity: 0;
transform: scale(0.85) translateY(30px);
filter: blur(4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
filter: blur(0);
}
}
Horizontal Scroll Gallery with Progress Indicator
.gallery-track {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-timeline: --gallery inline;
}
.gallery-progress {
height: 3px;
background: #2563eb;
transform-origin: left;
animation: track-gallery linear both;
animation-timeline: --gallery;
}
body {
timeline-scope: --gallery;
}
@keyframes track-gallery {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Accessibility: Respecting User Preferences
This part isn't optional. Some users experience motion sickness, vestibular disorders, or simply prefer reduced animation. Always wrap scroll-driven animations in a prefers-reduced-motion media query:
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.reveal {
animation: fade-in-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
}
/* Users who prefer reduced motion see content immediately */
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
}
}
This is a WCAG 2.1 requirement (Success Criterion 2.3.3). Failing to respect this preference can make your site physically uncomfortable — or even harmful — for some users. Please don't skip it.
Debugging Scroll-Driven Animations
Chrome DevTools provides solid tooling for debugging these animations:
- Animations panel — Open DevTools → More tools → Animations. You'll see each scroll-driven animation listed, and you can scrub through the timeline manually.
- Performance panel — Record a scroll session and check the Compositor track. Scroll-driven animations should appear as compositor tasks, not on the Main thread.
- Layers panel — Open DevTools → More tools → Layers. Verify that animated elements are promoted to their own compositor layer. If you see way too many layers, you've got layer explosion.
- Rendering tab — Enable "Paint flashing" to confirm that scroll-driven animations using
transformandopacitydon't trigger paint operations.
If your animation shows up on the Main thread in the Performance panel, check which CSS properties you're animating. Even a single non-compositor property will pull the entire animation to the main thread.
Common Pitfalls and How to Avoid Them
- Using
overflow: hiddenon ancestors — This can break the scroll-seeking mechanism. Useoverflow: clipinstead, which provides the same visual clipping without creating a scroll container. (This one trips up almost everyone at least once.) - Animating custom properties — CSS custom properties (
--my-var) always run on the main thread. If you need dynamic values, pre-calculate them and use standard properties in your keyframes. - Forgetting
animation-fill-mode: both— Withoutboth(orforwards), the element snaps back to its pre-animation state when the timeline passes the animation range. Always includebothin theanimationshorthand. - Mixing compositor and layout properties — One layout-triggering property poisons the entire animation. Split effects into separate
@keyframesblocks if you absolutely need both. - Not testing on mobile — Touch scrolling fires at different rates than mouse or trackpad. Always test on real devices to make sure things feel smooth with touch inertia and overscroll.
FAQ
Do CSS scroll-driven animations affect INP scores?
No. When you animate compositor-friendly properties (transform, opacity, filter), scroll-driven animations run entirely on the compositor thread. They have zero impact on the main thread and therefore zero impact on Interaction to Next Paint. This is their primary performance advantage over JavaScript-based scroll animation libraries.
Are CSS scroll-driven animations supported in Safari and Firefox?
Safari has full support since version 18 (September 2024). Firefox has experimental support behind the layout.css.scroll-driven-animations.enabled flag but hasn't shipped it by default as of April 2026. Chrome, Edge, and Opera have had full support since version 115. Combined, supporting browsers cover roughly 90% of global users.
Should I replace GSAP ScrollTrigger with CSS scroll-driven animations?
For simple effects like reveal-on-scroll, progress bars, and basic parallax — yes, absolutely. CSS scroll-driven animations are faster, lighter (zero bundle size), and have no INP impact. However, GSAP ScrollTrigger still wins for complex choreography, pinning, timeline sequencing, and projects that need universal browser support including Firefox. Plenty of production sites use both: CSS for the simple stuff and GSAP for the advanced sequences.
What happens in browsers that don't support scroll-driven animations?
If you wrap your animations in @supports (animation-timeline: view()), unsupported browsers simply skip the animation rules. Elements display in their final visible state with no broken layout. For projects that need animation everywhere, the scroll-timeline polyfill provides a JavaScript-based fallback — though without the off-main-thread performance benefits.
Can I use scroll-driven animations with React, Vue, or other frameworks?
Yep. Since scroll-driven animations are pure CSS, they work with any framework — no special bindings or hooks needed. Define your keyframes and timeline in CSS (or CSS modules, Tailwind @apply, or styled-components), add the appropriate class to your component's DOM element, and the browser handles everything. The framework never needs to know about the animation, which keeps your component logic cleaner and your bundle lighter.