Skip to main content
MEWA STUDIO

CSS Scroll-Driven Animations : Replacing JavaScript with Native CSS in 2026

Published on March 27, 2026|12 min read
CSSanimationperformance

Scroll-driven animations no longer need JavaScript. CSS scroll() and view() timelines let you build performant scroll effects with zero JS, off the main thread, with full GPU acceleration. Here is how to use them in production today.

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

A developer opens their editor. The task : animate an element to fade in as it enters the viewport on scroll. They write an IntersectionObserver. Then a scroll listener for the progress calculation. Then a requestAnimationFrame loop for smooth interpolation. Then a resize handler because the thresholds depend on the viewport. Forty-two lines of JavaScript later, the animation works. On desktop. On Chrome. When the user scrolls slowly enough.

Meanwhile, CSS can do the same thing in five lines. No JavaScript. No bundle size. No main thread. No jank.

This is not a future spec. CSS scroll-driven animations shipped in Chrome 115, Edge 115, Firefox 123, and landed in Safari with WebKit's Interop 2024 commitment (opens in a new tab). In 2026, they are production-ready. And they change everything about how we build scroll effects.

The JavaScript scroll problem nobody talks about

For over a decade, scroll-based animations have been a JavaScript monopoly. IntersectionObserver for triggering, GSAP's ScrollTrigger for progress-linked animations, custom scroll listeners for everything in between. These tools work. But they carry structural costs that accumulate quietly.

The main thread bottleneck

Every JavaScript scroll handler runs on the main thread. The same thread that handles layout, paint, user input, and React renders. When your scroll listener fires 60 times per second, it competes with everything else the browser is trying to do.

The result : scroll jank. That subtle stutter when the page hesitates mid-scroll, when animations lag behind the user's finger by 2-3 frames, when the experience feels "heavy" without anyone being able to articulate why. Google's own web performance guidelines (opens in a new tab) explicitly warn against scroll handlers that perform visual updates, because they block the compositor thread from doing its job.

The bundle size tax

GSAP's ScrollTrigger plugin adds approximately 25 KB minified and gzipped to your bundle. That is not enormous, but it adds up : GSAP core (27 KB) plus ScrollTrigger (25 KB) plus your animation code. For a landing page with a few scroll effects, you are shipping 50+ KB of JavaScript that native CSS can eliminate entirely.

On mobile connections, that 50 KB translates to 200-400 ms of parse and execution time, time your users spend looking at a static page waiting for animations to initialize.

The complexity overhead

Here is what a simple "fade in on scroll" looks like with IntersectionObserver :

javascript
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        entry.target.style.opacity = '1';
        entry.target.style.transform = 'translateY(0)';
        observer.unobserve(entry.target);
      }
    });
  },
  { threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
);

document.querySelectorAll('.reveal').forEach((el) => {
  el.style.opacity = '0';
  el.style.transform = 'translateY(30px)';
  el.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
  observer.observe(el);
});

IntersectionObserver: 20+ lines for a fade-in

This is the simple version. It does not handle cleanup, does not support progress-linked animation, does not reverse when the element leaves, and breaks if the DOM updates dynamically. The production version with GSAP adds another layer of abstraction, another dependency, another thing that can break on update.

CSS scroll-driven animations : the fundamentals

The CSS Scroll-Driven Animations specification (opens in a new tab) introduces two new timeline types that replace time-based progression with scroll-based progression. Instead of an animation running over 300 ms, it runs over the scroll distance of a container or the viewport traversal of an element.

The key concept : you write a standard CSS @keyframes animation, then swap the default time-based timeline for a scroll-based one. Everything you already know about CSS animations still applies : easing functions, fill modes, animation ranges. The only change is what drives the progress.

The two timeline types

TimelineDriven byTypical use caseScope
scroll()Scroll position of a containerProgress bars, parallax, global scroll effectsContainer-level
view()Element's visibility in the viewportReveal on scroll, element entry/exit animationsElement-level

scroll() vs view() : two approaches for two categories of scroll effects

The syntax in 30 seconds

css
/* 1. Define keyframes (same as any CSS animation) */
@keyframes fade-in {
  from {
    opacity: 0;
    transform: translateY(30px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* 2. Apply with a scroll-driven timeline */
.reveal {
  animation: fade-in linear both;
  animation-timeline: view();         /* driven by element visibility */
  animation-range: entry 0% entry 100%; /* animate during viewport entry */
}

Basic scroll-driven animation syntax

That is the entire implementation. Five declarations. No JavaScript. The browser handles all the scroll tracking, progress calculation, and rendering off the main thread.

scroll() in practice : container-level effects

The scroll() function creates a timeline linked to the scroll position of a scrollable container. At the top, the animation is at 0%. At the bottom, it is at 100%. Everything in between is interpolated.

Example 1 : reading progress bar

The classic blog progress bar at the top of the page, previously requiring a scroll listener with scrollY / (scrollHeight - clientHeight) calculations, becomes trivial :

css
.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  background: var(--color-primary);
  transform-origin: left;
  animation: grow-width linear both;
  animation-timeline: scroll(root);
}

@keyframes grow-width {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

Reading progress bar with scroll()

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

HTML: a single div

No JavaScript. No addEventListener('scroll'). No requestAnimationFrame. The browser's compositor handles the animation directly on the GPU, delivering a perfectly smooth bar even on low-end devices.

Example 2 : parallax background

Parallax effects traditionally require a scroll listener that applies a translateY based on scroll position. With CSS :

css
.parallax-bg {
  animation: parallax-shift linear both;
  animation-timeline: scroll();
}

@keyframes parallax-shift {
  from { transform: translateY(-20%); }
  to   { transform: translateY(20%); }
}

CSS-only parallax

The scroll() function without arguments defaults to the nearest scrollable ancestor on the block axis. You can also specify the scroller and axis explicitly : scroll(root block) targets the document's root scroller on the vertical axis.

Example 3 : header background on scroll

A sticky header that gains a background color as the user scrolls down, typically implemented with a scroll threshold listener, requires zero JavaScript :

css
.sticky-header {
  position: sticky;
  top: 0;
  animation: fill-header linear both;
  animation-timeline: scroll(root);
  animation-range: 0px 200px;
}

@keyframes fill-header {
  from {
    background: transparent;
    backdrop-filter: blur(0px);
    box-shadow: 0 0 0 transparent;
  }
  to {
    background: rgba(255, 255, 255, 0.9);
    backdrop-filter: blur(12px);
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  }
}

Header that fills in on scroll

The animation-range: 0px 200px constrains the animation to the first 200 pixels of scroll. After that, the header stays in its final state. This replaces the classic pattern of listening for scroll position and toggling a .scrolled class.

view() in practice : element-level effects

The view() function creates a timeline scoped to an individual element's intersection with its scrollport. When the element is completely out of view, the animation is at 0%. When it is fully visible, the animation reaches 100%. This is the CSS equivalent of IntersectionObserver, but with continuous progress instead of binary thresholds.

Understanding animation-range

The animation-range property controls which part of the view timeline drives the animation. The spec defines named ranges :

  • entry: from the element's leading edge touching the scrollport to its trailing edge clearing the entry edge
  • exit: from the element's leading edge touching the exit edge to its trailing edge clearing the scrollport
  • contain: the element is fully within the scrollport
  • cover: from first pixel entering to last pixel leaving (the full range)

Example 1 : reveal on scroll

css
.reveal-element {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(40px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

Fade-and-slide reveal using view()

The element gradually fades in and slides up as it enters the viewport. The animation is progress-linked : if the user scrolls back up, the element reverses its animation and fades out. This bidirectional behavior is built in, no extra code required.

Example 2 : horizontal slide with stagger

css
.card {
  animation: slide-in linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 80%;
}

.card:nth-child(2) { animation-delay: 50ms; }
.card:nth-child(3) { animation-delay: 100ms; }
.card:nth-child(4) { animation-delay: 150ms; }

@keyframes slide-in {
  from {
    opacity: 0;
    transform: translateX(-60px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

Staggered cards sliding in from the side

Example 3 : scale-up image reveal

css
.image-reveal {
  overflow: hidden;
}

.image-reveal img {
  animation: scale-up linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}

@keyframes scale-up {
  from {
    transform: scale(1.3);
    opacity: 0.6;
  }
  to {
    transform: scale(1);
    opacity: 1;
  }
}

Image scaling on viewport entry

Before and after : JavaScript vs CSS side by side

To understand the magnitude of this shift, let us compare the same effect implemented both ways : a section that fades in with a vertical slide as it enters the viewport.

The JavaScript way (GSAP ScrollTrigger)

javascript
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

// Set initial state
gsap.set('.section-reveal', {
  opacity: 0,
  y: 50,
});

// Create scroll-triggered animation
gsap.to('.section-reveal', {
  opacity: 1,
  y: 0,
  duration: 1,
  ease: 'power2.out',
  scrollTrigger: {
    trigger: '.section-reveal',
    start: 'top 80%',
    end: 'top 30%',
    scrub: 1,
  },
});

// Cleanup on unmount (React/Next.js)
// ScrollTrigger.getAll().forEach(t => t.kill());

GSAP ScrollTrigger implementation

Dependencies : gsap (27 KB gzipped) + gsap/ScrollTrigger (25 KB gzipped). Runs on the main thread. Requires cleanup logic in SPAs.

The CSS way

css
.section-reveal {
  animation: section-enter ease-out both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

@keyframes section-enter {
  from {
    opacity: 0;
    transform: translateY(50px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

Native CSS: same effect, zero dependencies

Dependencies : none. Runs off the main thread. No cleanup needed. Works in any framework, any SPA, any static site.

MetricGSAP ScrollTriggerCSS scroll-driven
Lines of code~25~12
Bundle size added~52 KB gzipped0 KB
ThreadMain threadCompositor thread
Cleanup in SPARequiredAutomatic
Framework dependencyImport + registerNone
Jank risk under loadModerate to highNear zero

JavaScript animation library vs native CSS for the same scroll-driven fade effect

Performance : why CSS wins on every metric

The performance advantage of CSS scroll-driven animations is not marginal. It is architectural. Here is why.

Off the main thread

CSS animations that only affect transform and opacity run entirely on the compositor thread. This is the browser's dedicated rendering layer that operates independently from JavaScript execution, DOM parsing, and layout calculations. When you animate with animation-timeline: scroll() or view(), the browser can calculate and apply the animation without touching the main thread at all.

In concrete terms : a JavaScript-heavy page with React hydration, analytics scripts, and third-party widgets will not affect your scroll animation smoothness. The two are decoupled at the browser engine level.

GPU acceleration by default

The browser automatically promotes scroll-driven animated elements to their own compositing layer when animating transform or opacity. This means the GPU handles the actual rendering, freeing the CPU for other work. With JavaScript scroll handlers, you need to manually ensure your animated properties are GPU-friendly, and you still pay the cost of the JavaScript execution itself.

Real-world performance numbers

Testing conducted by the Chrome for Developers team (opens in a new tab) shows consistent results across devices :

MetricJS scroll listenerCSS scroll-drivenImprovement
Main thread time (per frame)4 - 8 ms< 0.1 ms40 - 80x reduction
Frame drops (scrolling 60fps)5 - 15 % of frames< 1 % of frames5 - 15x fewer
Interaction to Next Paint (INP)Degraded under loadUnaffectedDecoupled
Battery usage (mobile, 60s scroll)Baseline~30 % lowerSignificant on mobile

Performance comparison : JavaScript scroll handlers vs CSS scroll-driven animations

The INP metric is particularly important. Google includes INP in Core Web Vitals, and any JavaScript that runs during scroll interactions contributes to INP degradation. CSS scroll-driven animations have zero impact on INP because they never touch the main thread.

Browser support in 2026

The browser support story for CSS scroll-driven animations has evolved rapidly. After initial concerns about Safari, WebKit shipped full support (opens in a new tab) as part of its Interop commitment, bringing the spec to universal availability.

BrowserVersionRelease dateStatus
Chrome115+July 2023Full support
Edge115+July 2023Full support
Firefox123+February 2024Full support
Safari18.4+2025Full support
Samsung Internet23+2024Full support

Browser support for CSS scroll-driven animations (source : Can I Use (opens in a new tab))

In March 2026, global browser support exceeds 92%, covering every modern evergreen browser. The remaining 8% consists primarily of older mobile browsers and legacy enterprise installations, cases that progressive enhancement handles gracefully.

Progressive enhancement : the deployment strategy

The safest way to ship CSS scroll-driven animations is progressive enhancement : the content works without animation, and the scroll effects layer on top for supported browsers. This is not a compromise, it is the correct architectural approach.

Strategy 1 : @supports with animation-timeline

css
/* Base: element is visible, no animation */
.reveal {
  opacity: 1;
  transform: translateY(0);
}

/* Enhanced: scroll-driven animation for supported browsers */
@supports (animation-timeline: view()) {
  .reveal {
    animation: reveal-scroll linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 100%;
  }

  @keyframes reveal-scroll {
    from {
      opacity: 0;
      transform: translateY(40px);
    }
    to {
      opacity: 1;
      transform: translateY(0);
    }
  }
}

Progressive enhancement with @supports

This is the recommended pattern. Unsupported browsers see the content immediately with no animation. Supported browsers get the scroll-driven effect. No polyfill, no JavaScript fallback, no maintenance burden.

Strategy 2 : JavaScript fallback for critical animations

If the scroll animation is a core part of the user experience (a product reveal, a data visualization), you can provide a JavaScript fallback for the shrinking minority of unsupported browsers :

javascript
// Only load JS animation if CSS scroll-driven is not supported
if (!CSS.supports('animation-timeline: view()')) {
  // Dynamically import a lightweight fallback
  import('./scroll-fallback.js').then((module) => {
    module.initScrollAnimations();
  });
}

Conditional JavaScript fallback

This approach gives you the best of both worlds : zero JavaScript for 92%+ of users, and a working fallback for the rest. The dynamic import ensures the fallback code never loads in supported browsers.

Strategy 3 : respecting motion preferences

Scroll-driven animations, like all animations, should respect the user's motion preferences. Combine @supports with prefers-reduced-motion:

css
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .reveal {
      animation: reveal-scroll linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 100%;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .reveal {
      /* Simpler fade, no transform movement */
      animation: reveal-fade linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 50%;
    }

    @keyframes reveal-fade {
      from { opacity: 0; }
      to   { opacity: 1; }
    }
  }
}

Combining scroll-driven animations with motion preferences

Users who prefer reduced motion still get a subtle opacity transition, without the spatial movement that can cause discomfort. This pattern treats accessibility as a first-class requirement, not an afterthought.

Practical use cases you can ship today

Beyond the basic examples, CSS scroll-driven animations unlock a range of production patterns that previously required substantial JavaScript. Here are four you can implement today.

1. Horizontal scroll section

A horizontal scrolling section driven by vertical scroll, a popular pattern on portfolio and product sites :

css
.horizontal-section {
  overflow: hidden;
  height: 300vh; /* creates scroll distance */
}

.horizontal-track {
  position: sticky;
  top: 0;
  display: flex;
  width: 400vw; /* 4 screens wide */
  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. Text highlight on scroll

Words or sentences that highlight progressively as the user scrolls through a block, commonly seen 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. Number counter

A CSS-only counter that animates as the stats section enters the viewport, using @property for animating numeric values :

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 number counter with @property

4. Scroll-synced background color

The page background color shifting as the user scrolls through different sections, 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 morphing on scroll

The limits, and when to keep JavaScript

CSS scroll-driven animations are powerful, but they are not a universal replacement for JavaScript animation. Knowing their boundaries is as important as knowing their capabilities.

When CSS falls short

  • Complex choreography with conditional logic: if animation B should only start after animation A completes, and only if the user has interacted with element C, CSS alone cannot express this. You need JavaScript orchestration.
  • User input-dependent animations: animations that depend on cursor position, mouse velocity, device orientation, or touch pressure require JavaScript to read those inputs and map them to animation parameters.
  • Scroll-triggered state changes: CSS scroll-driven animations interpolate between keyframes. They do not fire events. If you need to load data, trigger analytics, or update application state when a user scrolls to a section, you still need IntersectionObserver.
  • Canvas and WebGL: 3D scenes, particle systems, and shader-based effects driven by scroll position require a JavaScript animation loop that feeds scroll data into the rendering pipeline.
  • Cross-element coordination: animating element A based on the scroll position of element B in a different scroll container is not yet supported by the CSS spec.

The pragmatic approach

The smartest strategy is not CSS or JavaScript. It is CSS first, JavaScript when needed. For every scroll animation on your site, ask : can this be expressed as a keyframe interpolation driven by scroll position ? If yes, use CSS. If no, use JavaScript. Most production websites will find that 70-80% of their scroll effects fall into the CSS category.

"The best JavaScript is no JavaScript. CSS scroll-driven animations eliminate an entire category of code that developers have been writing, debugging, and maintaining for over a decade." - Smashing Magazine (opens in a new tab)

A migration checklist for existing projects

If you have a project with JavaScript-based scroll animations, here is a practical migration path :

  • Audit your scroll animations: list every scroll-triggered or scroll-linked animation. Categorize them as "pure interpolation" (CSS candidate) or "logic-dependent" (keep in JS).
  • Start with the progress bar: it is the simplest migration, the most visible win, and a good proof of concept for the team.
  • Migrate reveal animations: fade-in-on-scroll effects are the most common pattern and translate directly to animation-timeline: view().
  • Wrap in @supports: every CSS scroll-driven animation should be inside @supports (animation-timeline: view()) for progressive enhancement.
  • Add motion preference checks: nest @media (prefers-reduced-motion: no-preference) inside your @supports block.
  • Remove the JavaScript: once the CSS version is deployed and tested, remove the JS animation code and its dependencies. Track the bundle size reduction.
  • Measure: compare Lighthouse scores, INP values, and bundle sizes before and after migration.

The shift is structural

CSS scroll-driven animations are not a novelty feature. They represent a fundamental shift in how browsers handle scroll-based visual effects. For over a decade, developers had no choice but to use JavaScript for anything beyond the most basic CSS transitions. The scroll event was a JavaScript-only concept. The viewport intersection was a JavaScript-only concept.

That era is ending. The browser now understands scroll context natively, can run scroll-driven animations on the compositor thread, and exposes an API that is simpler than the JavaScript equivalent. The spec reached universal browser support in 2025. The tooling, documentation, and community patterns have matured through 2026.

For teams building production websites, the implications are concrete : smaller bundles, smoother animations, better Core Web Vitals, less code to maintain, and fewer runtime bugs. For agencies delivering client work, it means faster development, lower risk, and measurably better performance.

The question is no longer whether CSS scroll-driven animations are ready for production. It is whether you can justify still shipping JavaScript for effects that CSS handles natively.

Further reading