CSS Scroll-Driven Animations: Replacing JavaScript with Native CSS
CSS Scroll-Driven Animations let you replace dozens of lines of JavaScript with a few CSS properties. Performance, syntax, browser support, practical use cases: the complete guide to shipping them in production.

You know the scene. A designer delivers a mockup with a scroll-linked progress bar, scroll-triggered element reveals, and a subtle parallax on images. Three effects. Three patterns you have implemented dozens of times.
You open your editor. You install GSAP (47 KB minified), configure ScrollTrigger, write an IntersectionObserver with its callbacks, thresholds, and unobserve calls. Forty lines of JavaScript, a growing bundle, one more dependency to maintain. And all of it runs on the main thread, competing with rendering, user events, and the rest of your application logic.
Now imagine the same result in 5 lines of CSS. No dependency. No JavaScript. Direct execution by the browser's rendering engine, off the main thread. That is exactly what CSS Scroll-Driven Animations enable, and they are ready for production.
The problem with JavaScript for scroll animations
Before discussing the solution, let's diagnose the problem. Why do we use JavaScript for scroll-linked animations? Because, historically, CSS had no way to tie an animation to scroll position. We could animate on click, on hover, on load, but not on scroll. JavaScript was the only path.
The problem is that this path carries a significant technical cost.
The main thread bottleneck
Every scroll event in JavaScript runs on the browser's main thread. The same thread that handles HTML parsing, style calculation, layout, painting, and all your application logic. When you attach a listener to the scroll event, you add work to every frame, potentially 60 to 120 times per second.
The result? Jank: those visible micro-stutters when the browser fails to maintain a smooth 60 fps render. According to web.dev (opens in a new tab), a scroll handler that takes more than 10 ms per frame causes noticeable performance drops.
Code complexity
For a simple "reveal on scroll" (making an element appear when it enters the viewport), standard JavaScript code looks like this:
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
},
{
threshold: 0.1,
rootMargin: '0px 0px -50px 0px',
}
);
document.querySelectorAll('.reveal').forEach((el) => {
observer.observe(el);
});Classic reveal on scroll with IntersectionObserver
Plus the associated CSS:
.reveal {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}Styles for the JS reveal
27 lines to make an element appear on scroll. It works, but it is pure boilerplate: no business logic, just technical plumbing. Multiply by the number of effects on a page (progress bar, parallax, sticky transitions) and you end up with a 200-line animation file that does nothing but tell the browser what it already knows: the scroll position.
The cost of dependencies
Scroll animation libraries are everywhere in modern projects:
| Library | Size (minified + gzip) | Typical use |
|---|---|---|
| GSAP + ScrollTrigger | ~27 Ko | Complex scroll animations, pinning, timelines |
| Framer Motion | ~32 Ko | whileInView, scroll-linked React animations |
| Lenis + animation lib | ~8 Ko + lib | Smooth scroll + scroll animation |
| AOS (Animate On Scroll) | ~6 Ko | Simple reveal on scroll |
Size of the main scroll animation libraries in 2026
Every additional kilobyte of JavaScript impacts Total Blocking Time (TBT), one of the Core Web Vitals. For e-commerce sites, Google estimates that a 100 ms increase in TBT reduces conversions by 0.3 to 0.7% (source : web.dev (opens in a new tab)).
CSS Scroll-Driven Animations: the fundamentals
CSS Scroll-Driven Animations are a W3C specification that lets you tie a CSS animation to scroll progress rather than time. Instead of saying "this animation lasts 2 seconds," you say "this animation progresses from 0% to 100% between the start and end of the scroll." The reference documentation is available on MDN (opens in a new tab).
The concept relies on two types of timelines:
scroll(): ties the animation to the scroll progress of a container. The animation advances when the user scrolls down, reverses when scrolling up. Ideal for progress bars, parallax effects, headers that change.view(): ties the animation to an element's visibility in the viewport. The animation triggers when the element enters the visible area. Ideal for reveals, entrance transitions, animated counters.
The key property: animation-timeline
Everything rests on a single CSS property: animation-timeline. It replaces the default time-based timeline (auto) with a scroll-linked timeline. The principle is the same as a classic animation: you define @keyframes, then attach them not to a duration but to a scroll progression.
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.element {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}Basic structure of a scroll-driven animation
Three property lines. No JavaScript. No dependency. And the browser handles everything, including off-main-thread execution.
animation-range: fine-grained control
The animation-range property defines when the animation starts and ends within the timeline. For view(), the available values are:
entry: from the moment the element starts entering the viewport until it is fully visibleexit: from the moment the element starts leaving until it is fully gonecontain: the period during which the element is fully contained within the viewportcover: the full period, from first appearance to complete disappearance
You can combine and adjust with percentages: animation-range: entry 10% cover 50%; means the animation starts when the element has entered 10% and ends at the midpoint of the cover phase.
scroll() in practice
The scroll() function ties the animation to a container's overall scroll. It is the ideal timeline for effects that need to reflect the global page position.
Example 1: reading progress bar
The most classic effect: a bar at the top of the page that indicates reading progress. Here is the pure CSS version:
<div class="progress-bar" aria-hidden="true"></div>Progress bar HTML
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: #6366f1;
transform-origin: left;
animation: grow-progress linear both;
animation-timeline: scroll();
}Pure CSS progress bar with scroll()
Nine lines of CSS, zero JavaScript. The bar fills from left to right following the page scroll. To achieve the same result in JavaScript, you would need a scroll listener, a scrollTop / (scrollHeight - clientHeight) ratio calculation, and a style update on every frame.
Example 2: subtle image parallax
@keyframes parallax-shift {
from { transform: translateY(-20px); }
to { transform: translateY(20px); }
}
.hero-image {
animation: parallax-shift linear both;
animation-timeline: scroll();
}Native CSS parallax with scroll()
The image moves slightly slower than the content, creating the classic parallax depth effect. The translateY value controls the effect intensity: 20px for a subtle parallax, 80px for a more pronounced effect.
view() in practice
The view() function is the most direct replacement for IntersectionObserver. It ties the animation to the element's own visibility in the viewport.
Example 1: scroll reveal
The classic reveal (fade-in + slide-up when the element enters the viewport) takes just a few lines:
@keyframes reveal {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}Pure CSS scroll reveal with view()
The element is invisible at first. As soon as it enters the viewport, it slides up with a fade. The animation completes when the element has traveled 40% of the entry zone. No callback, no class to add, no cleanup.
Example 2: progressive image scale
@keyframes scale-up {
from {
transform: scale(0.8);
opacity: 0.5;
}
to {
transform: scale(1);
opacity: 1;
}
}
.gallery-image {
animation: scale-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}Progressive image zoom with view()
The image grows progressively as the user scrolls. Combined with overflow: hidden on the container, this effect creates a cinematic sense of depth.
Example 3: staggered grid animation
For a stagger effect (progressive delay between grid items), use animation-delay combined with view():
.grid-item {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 50%;
}
.grid-item:nth-child(3n + 2) {
animation-range: entry 10% entry 60%;
}
.grid-item:nth-child(3n + 3) {
animation-range: entry 20% entry 70%;
}Native CSS stagger on a grid
Each grid column has a slightly offset animation-range. The result: elements appear in a cascade, left to right, as they enter the viewport. All without a single line of JavaScript.
Before and after: JavaScript vs native CSS
Let's compare the same effect (scroll reveal) using both approaches. The goal is identical: make .card elements appear with a fade and vertical slide when they enter the viewport.
JavaScript approach (IntersectionObserver)
document.addEventListener('DOMContentLoaded', () => {
const cards = document.querySelectorAll('.card');
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1, rootMargin: '0px 0px -40px 0px' }
);
cards.forEach((card) => observer.observe(card));
});reveal.js, 22 lines
.card {
opacity: 0;
transform: translateY(40px);
transition: opacity 0.6s ease-out,
transform 0.6s ease-out;
}
.card.is-visible {
opacity: 1;
transform: translateY(0);
}reveal.css for the JS version, 12 lines
Total: 34 lines split across 2 files, a dependency on DOM loaded, an observer to instantiate and clean up.
Native CSS approach (Scroll-Driven Animations)
@keyframes reveal {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}reveal.css in pure CSS, 14 lines
Total: 14 lines, a single file, zero JavaScript. The browser does all the work.
GSAP + ScrollTrigger approach
For projects that used GSAP, the comparison is even more striking:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
gsap.utils.toArray('.card').forEach((card) => {
gsap.from(card, {
opacity: 0,
y: 40,
duration: 0.6,
ease: 'power2.out',
scrollTrigger: {
trigger: card,
start: 'top 90%',
toggleActions: 'play none none none',
},
});
});GSAP reveal with ScrollTrigger, 18 lines + 27 KB bundle
18 lines of JavaScript, plus ~27 KB of dependencies (GSAP core + ScrollTrigger). Native CSS achieves the same result in 14 lines and 0 KB of bundle. For sites where performance is critical (e-commerce, media, SaaS), the difference is significant.
Performance: why CSS wins
The performance argument goes beyond bundle size. The fundamental difference is architectural: CSS Scroll-Driven Animations run on the compositor thread, not the main thread.
Main thread vs compositor thread
The browser has multiple threads. The main thread handles JavaScript, the DOM, style calculation, and layout. The compositor thread handles final layer composition, GPU transforms, and on-screen rendering. When a CSS animation uses only transform and opacity (the "compositable" properties), it can run entirely on the compositor thread, without ever touching the main thread.
According to benchmarks from Chrome for Developers (opens in a new tab), scroll-driven animations using transform and opacity consistently reach 60 fps, even on low-end mobile devices, where JavaScript equivalents drop to 30-45 fps under load.
| Criterion | JavaScript (scroll listener) | CSS Scroll-Driven |
|---|---|---|
| Execution thread | Main thread (blocking) | Compositor thread (non-blocking) |
| Framerate under load | 30 - 45 fps (variable) | 60 fps (constant) |
| TBT impact | Increases Total Blocking Time | No impact |
| Bundle size | 6 - 32 KB (depending on lib) | 0 KB |
| JS parsing on load | Yes (blocks initial render) | No |
| Garbage collection | Yes (callbacks, closures) | No |
Performance comparison between JavaScript and CSS Scroll-Driven Animations
The concrete impact for users: smooth animations even on a budget Android smartphone, even when the page loads dynamic content in the background. For site owners, it means a better Lighthouse score, better Core Web Vitals, and potentially better search rankings.
Browser support in 2026
This is often the main objection: "Do browsers actually support this?" In March 2026, the answer is yes, widely.
| Browser | Support | Since |
|---|---|---|
| Chrome | Full | Version 115 (July 2023) |
| Edge | Full | Version 115 (July 2023) |
| Firefox | Full | Version 123 (February 2024) |
| Safari | Full (Interop 2024/2025) | Version 18.4 (2025) |
| Chrome Android | Full | Version 115 |
| Safari iOS | Full | Version 18.4 |
Browser support for CSS Scroll-Driven Animations, March 2026 (source: Can I Use (opens in a new tab))
With Safari joining the group, global support exceeds 95% of users. For the full documentation, the official WebKit guide (opens in a new tab) details the Safari implementation and recommended use cases.
Progressive enhancement: the deployment strategy
Even with 95% support, some users remain on older browsers. The best practice is progressive enhancement: provide a functional experience for everyone and an enriched experience for those whose browser supports the feature.
@supports: native detection
/* Base : l'élément est visible, pas d'animation */
.card {
opacity: 1;
transform: translateY(0);
}
/* Enhancement : si le navigateur supporte les scroll-driven animations */
@supports (animation-timeline: view()) {
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
}Progressive enhancement with @supports
This approach is clean and reliable. Browsers that don't support animation-timeline ignore the @supports block: the element stays visible, no content is hidden. Modern browsers apply the animation. Zero risk.
Fallback with IntersectionObserver
For projects that also want the animation on older browsers, combine native CSS and JavaScript as a fallback:
// Ne charger le fallback que si le navigateur ne supporte pas
if (!CSS.supports('animation-timeline', 'view()')) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
document.querySelectorAll('.card').forEach((el) => {
observer.observe(el);
});
}Conditional JS fallback for older browsers
JavaScript only loads and executes for the 5% of users who need it. The remaining 95% get the CSS version, lighter and more performant.
Practical use cases: 4 production-ready effects
Here are four effects you can deploy to production right now. Each is tested on supported browsers and uses progressive enhancement by default.
1. Reading progress bar
For a blog or media site, the progress bar is a visual indicator that improves the reading experience. According to Smashing Magazine (opens in a new tab), articles with a progress indicator have a 12% higher completion rate.
@keyframes progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.reading-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
transform-origin: left;
z-index: 1000;
}
@supports (animation-timeline: scroll()) {
.reading-progress {
animation: progress linear both;
animation-timeline: scroll();
}
}Complete, production-ready progress bar
2. Staggered reveal on a card grid
@keyframes card-reveal {
from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@supports (animation-timeline: view()) {
.card {
animation: card-reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 50%;
}
.card:nth-child(even) {
animation-range: entry 10% entry 60%;
}
}Card grid with staggered reveal
3. Image reveal with clip-path
@keyframes clip-reveal {
from {
clip-path: inset(0 100% 0 0);
opacity: 0;
}
to {
clip-path: inset(0 0 0 0);
opacity: 1;
}
}
@supports (animation-timeline: view()) {
.reveal-image {
animation: clip-reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
}Image reveal with animated clip-path
The image unveils from left to right as it enters the viewport. The effect is cinematic and clip-path is fully compositable, so it runs on the GPU.
4. Header that shrinks on scroll
@keyframes shrink-header {
from {
padding-block: 2rem;
background: transparent;
}
to {
padding-block: 0.5rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
}
}
@supports (animation-timeline: scroll()) {
.site-header {
position: sticky;
top: 0;
animation: shrink-header linear both;
animation-timeline: scroll();
animation-range: 0px 200px;
}
}Compact header on scroll with scroll()
The header transitions from a spacious state (large padding, transparent background) to a compact state (small padding, blurred background) over the first 200 pixels of scroll. Note: this animation affects padding, a property that triggers layout. It cannot run on the compositor thread. For optimal performance, prefer animating transform: scaleY() on a pseudo-element instead of padding directly.
4 advanced effects to go further
Beyond the classic use cases, CSS Scroll-Driven Animations enable effects that were thought to be JavaScript-only territory. Here are four advanced patterns, each production-ready.
1. Horizontal scroll driven by vertical scroll
A classic on portfolios and product sites: a section that scrolls horizontally while the user scrolls vertically.
.horizontal-section {
overflow: hidden;
height: 300vh; /* crée la distance de scroll */
}
.horizontal-track {
position: sticky;
top: 0;
display: flex;
width: 400vw; /* 4 écrans de large */
height: 100vh;
animation: scroll-horizontal linear both;
animation-timeline: scroll(nearest block);
}
@keyframes scroll-horizontal {
from { transform: translateX(0); }
to { transform: translateX(-300vw); }
}Horizontal scroll driven by vertical scroll
2. Progressive text highlighting on scroll
Words that progressively highlight during reading, a common effect on storytelling sites:
.highlight-text {
color: rgba(0, 0, 0, 0.2);
animation: text-highlight linear both;
animation-timeline: view();
animation-range: cover 20% cover 60%;
}
@keyframes text-highlight {
to {
color: rgba(0, 0, 0, 1);
}
}Progressive text highlighting
3. Animated counter with @property
A pure CSS counter that animates when the stats section enters the viewport. The trick relies on @property to animate a numeric value:
@property --num {
syntax: '<integer>';
inherits: false;
initial-value: 0;
}
.counter {
animation: count-up linear both;
animation-timeline: view();
animation-range: entry 50% cover 50%;
counter-reset: num var(--num);
}
.counter::after {
content: counter(num);
}
@keyframes count-up {
from { --num: 0; }
to { --num: 100; }
}Scroll-driven animated counter with @property
4. Background color that shifts on scroll
The page background color changes as the user scrolls, creating distinct visual zones without hard transitions:
body {
animation: bg-shift linear both;
animation-timeline: scroll(root);
}
@keyframes bg-shift {
0% { background-color: #ffffff; }
25% { background-color: #f0f4ff; }
50% { background-color: #fdf4f0; }
75% { background-color: #f0fdf4; }
100% { background-color: #f4f0fd; }
}Background color transition on scroll
The limits: when to keep JavaScript
CSS Scroll-Driven Animations don't replace everything. They excel at declarative, scroll-linked animations with predictable behavior. But some use cases remain JavaScript territory. For a deeper look at the possibilities and limits, CSS-Tricks (opens in a new tab) offers an excellent overview.
Complex choreographies
If your animations need to trigger in sequence with conditions ("play A, then wait 200 ms, then play B if the user has scrolled past section X"), JavaScript is still necessary. CSS timelines don't support conditional branching.
Business logic
If the animation depends on dynamic data (progress score, cart state, user profile), CSS cannot access that information. GSAP or Framer Motion remain relevant for these cases.
3D animation and WebGL
For Three.js scenes or WebGL experiences linked to scroll (Apple's MacBook Pro page, for example), JavaScript is essential. CSS Scroll-Driven Animations operate in the 2D plane of CSS properties, not in a WebGL canvas.
Smooth scrolling and scroll behavior control
If you use Lenis or other libraries to modify scroll behavior itself (inertia, smooth, advanced snapping), CSS Scroll-Driven Animations don't replace them. They animate based on scroll, but they don't modify the scroll itself.
The decision guide
| Need | Recommended solution |
|---|---|
| Reveal on scroll (fade, slide, scale) | CSS Scroll-Driven via view() |
| Reading progress bar | CSS Scroll-Driven via scroll() |
| Subtle parallax | CSS Scroll-Driven via scroll() |
| Compact header on scroll | CSS Scroll-Driven via scroll() |
| 3D/WebGL animation linked to scroll | JavaScript (Three.js + ScrollTrigger) |
| Complex conditional choreography | JavaScript (GSAP timelines) |
| Animation dependent on dynamic data | JavaScript (Framer Motion / GSAP) |
| Modifying scroll behavior | JavaScript (Lenis, smooth scroll) |
When to use CSS Scroll-Driven vs JavaScript for animations
Conclusion: the shift is underway
CSS Scroll-Driven Animations are not an experimental curiosity. They are supported by all major browsers, they offer superior performance to JavaScript, and they reduce code complexity drastically.
The pattern is familiar. We saw it with Flexbox (which replaced float hacks), with CSS Grid (which replaced JS grid frameworks), with position: sticky (which replaced dozens of jQuery plugins). Every time, the same cycle: JavaScript fills a gap, CSS catches up, JavaScript loses its reason to exist for that use case.
In 2026, we are at the tipping point for scroll animations. Reveals, progress bars, subtle parallax effects, header transitions: all of these can (and should) be done in native CSS. Reserve JavaScript for what CSS cannot do: 3D, conditional logic, data-driven animations.
The result? A lighter bundle, better performance, more maintainable code, and users who will never see a stutter. That is exactly the kind of technical gain that translates directly into a business advantage.