Skip to main content
MEWA STUDIO

CSS Scroll-Driven Animations: Replacing JavaScript with Native CSS

Published on March 27th, 2026|12 min read
CSSanimationperformance

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.

White water drop outlets arranged in circular lines on a dark blue background, evoking the flow of CSS Scroll-Driven Animations

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:

javascript
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:

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:

LibrarySize (minified + gzip)Typical use
GSAP + ScrollTrigger~27 KoComplex scroll animations, pinning, timelines
Framer Motion~32 KowhileInView, scroll-linked React animations
Lenis + animation lib~8 Ko + libSmooth scroll + scroll animation
AOS (Animate On Scroll)~6 KoSimple 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.

css
@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 visible
  • exit: from the moment the element starts leaving until it is fully gone
  • contain: the period during which the element is fully contained within the viewport
  • cover: 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:

html
<div class="progress-bar" aria-hidden="true"></div>

Progress bar HTML

css
@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

css
@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:

css
@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

css
@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():

css
.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)

javascript
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

css
.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)

css
@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:

javascript
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.

CriterionJavaScript (scroll listener)CSS Scroll-Driven
Execution threadMain thread (blocking)Compositor thread (non-blocking)
Framerate under load30 - 45 fps (variable)60 fps (constant)
TBT impactIncreases Total Blocking TimeNo impact
Bundle size6 - 32 KB (depending on lib)0 KB
JS parsing on loadYes (blocks initial render)No
Garbage collectionYes (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.

BrowserSupportSince
ChromeFullVersion 115 (July 2023)
EdgeFullVersion 115 (July 2023)
FirefoxFullVersion 123 (February 2024)
SafariFull (Interop 2024/2025)Version 18.4 (2025)
Chrome AndroidFullVersion 115
Safari iOSFullVersion 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

css
/* 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:

javascript
// 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.

css
@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

css
@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

css
@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

css
@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.

css
.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:

css
.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:

css
@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:

css
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

NeedRecommended solution
Reveal on scroll (fade, slide, scale)CSS Scroll-Driven via view()
Reading progress barCSS Scroll-Driven via scroll()
Subtle parallaxCSS Scroll-Driven via scroll()
Compact header on scrollCSS Scroll-Driven via scroll()
3D/WebGL animation linked to scrollJavaScript (Three.js + ScrollTrigger)
Complex conditional choreographyJavaScript (GSAP timelines)
Animation dependent on dynamic dataJavaScript (Framer Motion / GSAP)
Modifying scroll behaviorJavaScript (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.