What Are CSS Scroll-Driven Animations?

CSS scroll-driven animations are standard CSS animations — defined with @keyframes and the animation shorthand — where the timeline is driven by scroll position rather than time. As the user scrolls, the animation progresses. Stop scrolling and the animation freezes at exactly that point. Scroll back and the animation reverses.

This is fundamentally different from time-based animations like animation: spin 2s linear infinite, where the animation runs on a clock regardless of user action. Scroll-driven animations are inherently interactive — they respond in real time to the user's scroll position.

Before this feature, developers relied on JavaScript's IntersectionObserver API or scroll event listeners to trigger CSS class changes, which caused layout thrashing, jank, and added kilobytes of JavaScript. CSS scroll-driven animations run off the main thread, making them dramatically more performant.

✓ Performance benefit

CSS scroll-driven animations are composited off the main thread — the same performance tier as transform and opacity transitions. They don't cause layout or paint, making them safe to use for complex scroll effects without performance concerns.

The feature is controlled by a single new CSS property: animation-timeline. Set it on any element with an existing @keyframes animation and the browser replaces the clock with the scroll position.

scroll() vs view() — The Two Timelines

There are two timeline functions. Understanding when to use each one is the most important concept in this entire guide.

scroll() — ScrollTimeline

The scroll() function creates a ScrollTimeline that tracks the scroll position of a container element. The animation is at 0% when the container is scrolled to the top and 100% when scrolled to the bottom.

CSS
/* scroll() syntax */
animation-timeline: scroll();            /* track nearest scrollable ancestor */
animation-timeline: scroll(root);        /* track the page scroll (html element) */
animation-timeline: scroll(self);        /* track this element's own scroll */
animation-timeline: scroll(nearest);     /* same as default — nearest ancestor */

/* Full example: element animates as the page scrolls */
.element {
  animation: myAnimation linear;
  animation-timeline: scroll(root);
}

Use scroll() when: the animation should reflect the overall scroll progress of the page or a scrollable container — e.g. a reading progress bar that fills as you read the article.

view() — ViewTimeline

The view() function creates a ViewTimeline that tracks the visibility of the animated element itself in the viewport. The timeline starts when the element begins to enter the viewport and ends when it finishes leaving the viewport.

CSS
/* view() syntax */
animation-timeline: view();              /* track element visibility in viewport */
animation-timeline: view(block);         /* track on block axis (vertical, default) */
animation-timeline: view(inline);        /* track on inline axis (horizontal) */
animation-timeline: view(block 100px);   /* with inset — shrink the tracked area */

/* Full example: element animates as it enters the viewport */
.card {
  animation: fadeInUp linear both;
  animation-timeline: view();
}

Use view() when: the animation should trigger based on the element's own position in the viewport — e.g. a card that fades in as it scrolls into view, or an image that zooms as you pass it.

CSS — Quick comparison
/* scroll() → progress bar tied to page scroll */
.progress-bar {
  animation: grow-bar linear;
  animation-timeline: scroll(root);
}

/* view() → card reveals as it enters viewport */
.card {
  animation: fade-slide-up linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 40%;
}

Understanding animation-range

animation-range controls which part of the timeline triggers the animation. Without it, the animation runs from the very start to the very end of the timeline. With it, you can specify a precise window.

The syntax is animation-range: <start> <end>. For view() timelines, range keywords are available:

  • entry — the element is entering the viewport (bottom edge of viewport to element fully in view)
  • exit — the element is leaving the viewport
  • cover — spans the entire time the element covers any part of the viewport
  • contain — spans when the element is fully visible within the viewport
CSS
/* Animate during entry only — when element enters viewport */
.reveal {
  animation-range: entry 0% entry 100%;
}

/* Animate only in the first 40% of entry — fast reveal */
.card {
  animation-range: entry 0% entry 40%;
}

/* Animate the entire time element is in view (cover) */
.parallax {
  animation-range: cover 0% cover 100%;
}

/* Start animation halfway through entry */
.delayed {
  animation-range: entry 50% exit 0%;
}

For scroll() timelines, you use percentages directly: animation-range: 0% 100% (the full scroll) or animation-range: 20% 80% (middle section of the scroll).

Example 1: Reading Progress Bar

The most iconic scroll-driven animation — a progress indicator that fills as the user reads. Previously required JavaScript to calculate scroll percentage. Now it's four lines of CSS.

CSS + HTML
<div class="reading-progress"></div>

.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  width: 100%;
  background: linear-gradient(90deg, #7c6fff, #ff6fb0);
  transform-origin: left;
  transform: scaleX(0);  /* start at 0 width */

  /* Link to page scroll */
  animation: grow-bar linear forwards;
  animation-timeline: scroll(root);
}

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

The transform: scaleX() approach is preferred over width because it's composited — no layout calculations, no repaints. The bar scales from 0 to 1 exactly in sync with the page scroll position.

💡 Sticky positioning

Always use position: fixed for a reading progress bar so it stays at the top of the viewport while the page scrolls. The animation-timeline: scroll(root) still tracks the document scroll regardless of the element's own position.

Example 2: Reveal on Scroll (Fade + Slide)

The most common scroll animation pattern — elements that fade and slide in as they enter the viewport. With view(), this requires zero JavaScript and automatically works for any element you add the class to.

CSS
/* Define the keyframes */
@keyframes fade-slide-up {
  from {
    opacity: 0;
    transform: translateY(40px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Apply to any element you want to animate on scroll */
.scroll-reveal {
  animation: fade-slide-up linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 40%;  /* complete in first 40% of entry */
}

/* Stagger variant — use CSS custom property for delay */
.scroll-reveal:nth-child(2) { --delay: 0.05s; }
.scroll-reveal:nth-child(3) { --delay: 0.10s; }
.scroll-reveal:nth-child(4) { --delay: 0.15s; }
/* Note: animation-delay doesn't work with scroll timelines — use animation-range instead */
HTML usage
<!-- Any element gets the reveal treatment -->
<section class="scroll-reveal">...</section>
<div class="card scroll-reveal">...</div>
<p class="scroll-reveal">...</p>
⚠ animation-fill-mode: both

The both keyword in animation: fade-slide-up linear both is essential. both applies the from keyframe before the animation starts (so elements start invisible) and keeps the to keyframe after it ends (so elements stay visible). Without it, elements appear on page load and then hide again as the animation initialises.

Example 3: Parallax Background

Parallax — where elements scroll at different speeds — previously required JavaScript to calculate offsets. With scroll-driven animations, you can build a simple parallax effect by animating transform: translateY() on a background element linked to the page scroll.

CSS + HTML
<section class="parallax-section">
  <div class="parallax-bg"></div>
  <div class="parallax-content">
    <h2>Section Title</h2>
  </div>
</section>

.parallax-section {
  position: relative;
  overflow: hidden;
  height: 500px;
}

.parallax-bg {
  position: absolute;
  inset: -20%;  /* larger than parent to allow movement */
  background: url('/your-image.jpg') center/cover;
  animation: parallax-move linear;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}

@keyframes parallax-move {
  from { transform: translateY(-15%); }
  to   { transform: translateY(15%); }
}

.parallax-content {
  position: relative;  /* sits above the animated background */
  z-index: 1;
}

The background starts 15% higher than its natural position and ends 15% lower, creating a total of 30% movement relative to the section. The section's overflow: hidden clips the edges of the oversized background so the seams don't show. Adjust the percentage values to control the parallax intensity — smaller values are subtler and less likely to cause motion sickness.

Example 4: Sticky Nav That Shrinks on Scroll

A sticky header that starts large and shrinks as the user scrolls down is a classic UI pattern. Traditionally done with a JavaScript scroll listener that toggles a CSS class. With scroll-driven animations, the transition is butter-smooth and JavaScript-free.

CSS
header {
  position: sticky;
  top: 0;
  z-index: 100;
  padding: 24px 32px;
  background: rgba(10,10,15,0.85);
  backdrop-filter: blur(12px);

  /* Link header to page scroll */
  animation: shrink-header linear forwards;
  animation-timeline: scroll(root);
  animation-range: 0px 120px;  /* effect completes after 120px of scroll */
}

@keyframes shrink-header {
  from {
    padding: 24px 32px;
    background: rgba(10,10,15,0.50);
    box-shadow: none;
  }
  to {
    padding: 12px 32px;
    background: rgba(10,10,15,0.95);
    box-shadow: 0 2px 20px rgba(0,0,0,0.4);
  }
}

/* Logo also shrinks */
.logo {
  font-size: 1.4rem;
  animation: shrink-logo linear forwards;
  animation-timeline: scroll(root);
  animation-range: 0px 120px;
}

@keyframes shrink-logo {
  from { font-size: 1.4rem; }
  to   { font-size: 1.1rem; }
}

The animation-range: 0px 120px uses pixel values instead of percentages, which means the effect completes after exactly 120px of scroll — regardless of the total page height. This is usually preferable for nav shrink effects so they feel consistent on pages of any length.

Example 5: Staggered Card Reveal

Revealing a grid of cards in a staggered sequence — where each card animates slightly after the previous — requires careful handling with scroll-driven animations, since animation-delay doesn't work with scroll timelines. The trick is to use animation-range offsets instead.

CSS
@keyframes card-reveal {
  from {
    opacity: 0;
    transform: translateY(30px) scale(0.96);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

/* Base card reveal */
.card-grid .card {
  animation: card-reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 50%;
}

/* Stagger by shifting the animation-range start point */
.card-grid .card:nth-child(1) { animation-range: entry 0%  entry 50%; }
.card-grid .card:nth-child(2) { animation-range: entry 10% entry 60%; }
.card-grid .card:nth-child(3) { animation-range: entry 20% entry 70%; }
.card-grid .card:nth-child(4) { animation-range: entry 30% entry 80%; }
.card-grid .card:nth-child(5) { animation-range: entry 0%  entry 50%; }
.card-grid .card:nth-child(6) { animation-range: entry 10% entry 60%; }

/* For larger grids, use a custom property set in HTML */
/* <div class="card" style="--i:3"> */
.card {
  animation-range:
    calc(entry 0% + var(--i, 0) * 10%)
    calc(entry 50% + var(--i, 0) * 10%);
}

Example 6: Image Zoom on Scroll

An image that subtly scales up as it enters the viewport creates a cinematic feel without adding visual noise. This works especially well for hero images and full-width photo sections.

CSS + HTML
<figure class="scroll-zoom">
  <img src="your-image.jpg" alt="Description">
</figure>

.scroll-zoom {
  overflow: hidden;  /* clip the zoomed image */
  border-radius: 16px;
}

.scroll-zoom img {
  width: 100%;
  display: block;
  transform-origin: center;
  animation: zoom-in linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}

@keyframes zoom-in {
  from {
    transform: scale(1.12);
    filter: brightness(0.85);
  }
  to {
    transform: scale(1);
    filter: brightness(1);
  }
}

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .scroll-zoom img {
    animation: none;
    transform: scale(1);
    filter: brightness(1);
  }
}

Combining with @keyframes

Scroll-driven animations use the exact same @keyframes syntax as regular CSS animations. The only change is swapping animation-duration + a time-based timeline for animation-timeline. This means you can reuse your existing keyframes:

CSS
/* The SAME @keyframes can be used time-based OR scroll-driven */
@keyframes spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

/* Time-based (normal) */
.spinner { animation: spin 2s linear infinite; }

/* Scroll-driven — rotates in sync with page scroll */
.scroll-spinner {
  animation: spin linear;
  animation-timeline: scroll(root);
}

/* You can also run MULTIPLE animations simultaneously */
.element {
  animation:
    fade-in 0.3s ease both,           /* time-based: fires once on load */
    float-up linear both;              /* scroll-driven: synced to scroll */
  animation-timeline:
    auto,                              /* auto = time-based (default) */
    view();                            /* override second animation */
}

The auto value in animation-timeline explicitly uses the document timeline (time-based). When specifying multiple animations with multiple timelines, they map positionally — first animation gets first timeline, second animation gets second timeline.

Browser Support and Fallbacks

CSS scroll-driven animations have reached broad support as of mid-2026, but you should still write graceful fallbacks for older browsers.

Browser Version Status Notes
Chrome 115+ ✓ Full support Stable since July 2023
Edge 115+ ✓ Full support Same Blink engine as Chrome
Firefox 110+ (flag), 132+ (stable) ✓ Full support Stable since October 2024
Safari 18+ ✓ Full support Stable since September 2024
iOS Safari 18+ ✓ Full support Same as desktop Safari
Samsung Internet 23+ ✓ Full support Based on Chromium 115

Global support is approximately 84% as of July 2026. Always use @supports to provide a fallback for the remaining 16%:

CSS — Graceful fallback pattern
/* Fallback: elements are always visible */
.scroll-reveal {
  opacity: 1;
  transform: none;
}

/* Enhancement: scroll animation only if supported */
@supports (animation-timeline: scroll()) {
  .scroll-reveal {
    opacity: 0;
    animation: fade-slide-up linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 40%;
  }
}

/* Always respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .scroll-reveal {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
}
⚠ Always include reduced motion

Scroll-driven parallax and large transforms can trigger vestibular disorders in users with motion sensitivity. The @media (prefers-reduced-motion: reduce) media query must be included in any animation that moves elements significantly. This is both an accessibility requirement and a legal compliance consideration in many jurisdictions.

Build your CSS @keyframes visually

Design your animation keyframes — fade, slide, bounce, spin — with live preview. Copy the @keyframes code and link it to your scroll timeline.

Open CSS Animation Generator →

Frequently Asked Questions

What are CSS scroll-driven animations?

CSS scroll-driven animations link the progress of a CSS @keyframes animation to the scroll position of a container, using the animation-timeline property. Instead of running on a clock, the animation progresses as the user scrolls. They require no JavaScript and run off the main thread for maximum performance.

What is the difference between scroll() and view() in CSS animations?

scroll() tracks the scroll position of a scroll container — the animation is at 0% when the container is at the top and 100% at the bottom. view() tracks the visibility of the animated element itself in the viewport — the animation progresses as the element enters and exits view. Use scroll() for page progress indicators; use view() for reveal-on-scroll effects.

Do CSS scroll-driven animations require JavaScript?

No — they are entirely pure CSS. No IntersectionObserver, no scroll event listeners, no external libraries. The animation is handled natively by the browser's rendering engine, making them more performant than any JavaScript alternative.

What is animation-range in CSS?

animation-range defines the portion of the scroll timeline during which the animation runs. For view() timelines, use keywords like entry (element entering viewport), exit (leaving), cover (any part visible) and contain (fully visible), combined with percentages: animation-range: entry 0% entry 40%.

Can I use animation-delay with scroll-driven animations?

No — animation-delay doesn't work with scroll timelines because there's no concept of "waiting" when the timeline is driven by scroll position. Instead, use animation-range offsets to stagger animations: start each subsequent element's animation range slightly later (e.g. entry 0%, entry 10%, entry 20%).

What browsers support CSS scroll-driven animations?

Chrome 115+, Edge 115+, Firefox 132+ (stable), and Safari 18+. Global support is approximately 84% as of mid-2026. Always include @supports (animation-timeline: scroll()) and a visible fallback for unsupported browsers, and always include @media (prefers-reduced-motion: reduce) for accessibility.

Are CSS scroll-driven animations performance-friendly?

Yes — when animating composited properties (transform and opacity), scroll-driven animations run entirely off the main thread with no layout or paint cost. Avoid animating width, height, top, left, margin, or padding in scroll animations as these trigger layout recalculation.