CSSAWWWARDS
+ Submit tool
animationsIntermediate · Updated July 2026

CSS Scroll-Driven Animations: Complete Guide to Scroll & View Timelines (2026)

Scroll progress bars, parallax effects, and reveal-on-scroll animations used to require JavaScript. With animation-timeline: scroll() and view(), CSS handles all of it natively — no Intersection Observer, no GSAP, no event listeners.

By Adil Badshah21 July 202614 min read
CSS Scroll-Driven Animations Complete Guide 2026

What Are CSS Scroll-Driven Animations?

Quick Answer

CSS scroll-driven animations are animations whose playhead is controlled by scroll position rather than elapsed time. Set animation-timeline: scroll() to link an animation to a scroll container’s progress, or animation-timeline: view()to link it to an element’s position within the visible area. No JavaScript required — the browser handles the connection between scroll and animation on the compositor thread.

Standard CSS animations play on a time axis: you define a duration in seconds and the animation runs from start to finish in that time. Scroll-driven animations replace the time axis with a scroll position axis. Instead of asking “how many seconds have passed?”, the browser asks “how far has the user scrolled?” — and uses that fraction to determine where in the animation to render.

This single change unlocks a category of effects that previously required JavaScript: scroll progress indicators, parallax depth, sticky headers that animate as you scroll past them, and elements that fade or slide in as they enter the viewport. Because the animation runs on the compositor thread (for transform and opacity), it is inherently smoother than JavaScript scroll handlers — no requestAnimationFrame loop, no getBoundingClientRect() on every scroll tick.

The CSS scroll-driven animations spec introduces two new timeline functions (scroll() and view()), a new property (animation-range), and support for named timelines (scroll-timeline and view-timeline). Chrome 115 shipped the initial implementation in July 2023, and the spec has since reached stable support across all major browsers.

animation-timeline: scroll() — Scroll Progress Animations

Quick Answer

animation-timeline: scroll() links an element’s animation to the scroll progress of its nearest scrollable ancestor. At 0% scroll the animation is at its from keyframe; at 100% scroll it is at its to keyframe. Do not set animation-duration — scroll position replaces time as the driver.

The scroll() function accepts two optional arguments: scroller and axis.

/* scroll() function syntax */animation-timeline: scroll();  /* Nearest scrollable ancestor, both axes */ animation-timeline: scroll(root);  /* The root scrollport (the page itself) */ animation-timeline: scroll(nearest block);  /* Nearest ancestor, block (vertical) axis */ animation-timeline: scroll(self inline);  /* The element itself, inline (horizontal) axis */ /* Axis values: block | inline | x | y *//* Scroller values: nearest | root | self */

The scroller argument specifies which scroll container to track: nearest (default — the nearest scrollable ancestor), root (the document root / page scroll), or self (the element itself if it is scrollable). The axis argument specifies which axis to track: block (default — vertical in horizontal writing mode), inline (horizontal), y, or x.

When you set animation-timeline to a scroll() value, any animation-duration you set is ignored — the scroll position entirely controls where in the animation the browser renders. This is why you omit animation-duration (or you can set it to auto) in scroll-driven animations.

Important: The animation-fill-mode: both property is essential for scroll-driven animations. Without it, the animation snaps back to its initial state when the user scrolls back to the top. both means the animation holds its from state at 0% scroll and its to state at 100% scroll.

animation-timeline: view() — Element Visibility Animations

Quick Answer

animation-timeline: view() links an element’s animation to its own visibility within the scroll container. The animation progresses from 0% (element just begins entering the viewport) to 100% (element has fully exited). Combine with animation-range: entry 0% entry 40% to run the animation only while the element is entering — perfect for reveal effects.

While scroll() tracks the entire scroll container, view()tracks an individual element’s position relative to the visible area. This is the tool for element-specific scroll effects.

/* view() function syntax */animation-timeline: view();  /* Tracks this element's visibility in the nearest scroller */ animation-timeline: view(block);  /* Block axis only (most common — vertical scroll) */ animation-timeline: view(inline 20px);  /* Inline axis with 20px inset on both sides */ /* The inset parameter shrinks the visible area:   view(block 100px) means the "visible" range   starts 100px before the element enters and   ends 100px after it exits — useful for   triggering reveals slightly before elements   reach the viewport edge */

The view timeline is divided into phases based on the element’s relationship to the scroll container’s visible region:

PhaseWhen it occursanimation-range keyword
entryElement scrolls into the visible areaentry 0% entry 100%
exitElement scrolls out of the visible areaexit 0% exit 100%
coverElement overlaps the visible area (entry + contain + exit)cover 0% cover 100%
containElement is fully inside the visible areacontain 0% contain 100%

For reveal effects, you almost always want the entry range — run the animation while the element enters the viewport, and leave it in its final state after it is fully visible.

animation-range — Start and End Points

Quick Answer

animation-range: entry 0% entry 30%tells the browser to run the animation only during the first 30% of the element’s entry phase. This prevents the animation from playing across the entire scroll range and instead completes quickly as the element appears — the natural behaviour users expect from reveal animations.

/* animation-range controls which part of the timeline   the animation maps to */ /* Reveal on enter — runs as element enters viewport */.reveal {  animation: fade-in linear both;  animation-timeline: view();  animation-range: entry 0% entry 30%;  /* Animation runs from when element starts entering     to when it is 30% inside the viewport */} /* Exit fade — runs as element leaves */.fade-out {  animation: fade-out linear both;  animation-timeline: view();  animation-range: exit 0% exit 100%;} /* Cover range — full time element overlaps viewport */.sticky-progress {  animation: progress linear both;  animation-timeline: view();  animation-range: cover 0% cover 100%;}

animation-range is a shorthand for animation-range-start and animation-range-end. The value consists of a timeline phase keyword (entry, exit, cover, contain, entry-crossing, exit-crossing) followed by a percentage within that phase.

For scroll-progress animations (using scroll()), you typically leave animation-range at its default (normal, covering the full 0%–100% scroll range). For element-reveal animations (using view()), you typically constrain the range to the entry phase to avoid the animation reversing as the user scrolls further.

Named Scroll & View Timelines

The scroll() and view() functions attach a timeline to the nearest ancestor by default. For more precise control — referencing a specific scroll container or observing a specific element from a sibling — you use named timelines.

/* Named scroll timelines — for animations that reference   a specific scroll container by name */ /* 1. Declare the named timeline on the scroller */.scroll-container {  overflow-y: scroll;  scroll-timeline-name: --card-scroll;  scroll-timeline-axis: block;} /* Shorthand */.scroll-container {  scroll-timeline: --card-scroll block;} /* 2. Reference it from a child or sibling element */.progress-indicator {  animation: grow-width linear both;  animation-timeline: --card-scroll;} /* This allows you to track a custom scroll container   (a modal, a sidebar) rather than the root page */

Named view timelines let you observe one element and drive an animation on a completely different element — enabling effects like a sidebar indicator that highlights as a section header scrolls into view:

/* Named view timelines — attach a view timeline to a specific   element and reference it from another */ /* 1. Register the view timeline on the observed element */.section-header {  view-timeline-name: --section-header;  view-timeline-axis: block;   /* Shorthand */  view-timeline: --section-header block;} /* 2. Drive an animation on a different element   using that view timeline */.sidebar-indicator {  animation: highlight-active linear both;  animation-timeline: --section-header;  animation-range: entry 30% exit 0%;}

Named timelines unlock cross-element choreography: one element’s visibility drives another element’s animation. This is powerful for building reading-progress sidebars, active-section highlighting in navigation, and any effect where the observed element and the animated element are not the same DOM node.

Build a Reading Progress Bar

A scroll progress indicator is the “hello world” of scroll-driven animations. Here is the complete implementation — a fixed bar at the top of the page that fills from left to right as the user scrolls:

/* ── Reading progress bar — pure CSS, zero JavaScript ── */ /* The bar element */.progress-bar {  position: fixed;  top: 0;  left: 0;  width: 100%;  height: 4px;  background: #1D9E75;  transform-origin: left center;  z-index: 9999;} /* @keyframes: from invisible to full-width */@keyframes grow-width {  from { transform: scaleX(0); }  to   { transform: scaleX(1); }} /* Link the animation to the page scroll */@media (prefers-reduced-motion: no-preference) {  .progress-bar {    animation: grow-width linear both;    animation-timeline: scroll(root block);    /* No duration — scroll position drives progress */  }}

The key insight is transform-origin: left center combined with a scaleX(0 → 1) animation. This scales the element horizontally from its left edge — creating the fill-from-left behaviour. The @keyframes here are just two lines, but if you want to prototype variations (colour shifts, opacity pulses, multi-step fills), the CSS Keyframes Builder lets you author and preview @keyframes visually before copying the output. Using transform: scaleX() instead of animating width is critical for performance: it runs on the compositor thread and avoids layout recalculation on every scroll event.

The HTML is minimal — just a single <div> at the top of the <body>:

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

Add aria-hidden="true"since the bar is decorative — screen readers don’t need to announce it.

Build a Parallax Scroll Effect

Parallax — where background elements move at a different speed than the foreground — creates a sense of depth. Here is the pure CSS implementation using animation-timeline: view():

/* ── Parallax scroll effect ── */ /* The image scrolls at half the speed of the page */@keyframes parallax-shift {  from { transform: translateY(-20%); }  to   { transform: translateY(20%);  }} @media (prefers-reduced-motion: no-preference) {  .parallax-image {    animation: parallax-shift linear both;    animation-timeline: view();    animation-range: cover 0% cover 100%;    /* The image moves 40% total (from -20% to +20%)       across the entire time the section overlaps       the viewport — creating a parallax depth effect */  }} /* Wrap the image in an overflow: hidden container   to clip the excess movement */.parallax-wrapper {  overflow: hidden;  height: 500px;}

The cover range runs the animation across the entire time the section is visible — from when its top edge reaches the bottom of the viewport to when its bottom edge leaves the top. During this range, the image translates from -20% to +20%— a total of 40% movement against the section’s 100% scroll distance. This ratio (40% image movement : 100% section movement = 0.4) creates the parallax depth perception.

Accessibility: Parallax motion is a known trigger for vestibular disorders. Always wrap parallax animations in @media (prefers-reduced-motion: no-preference). When reduced motion is preferred, either show a static image or reduce the translation to a near-zero value.

Sticky section header progress

A common variation uses a named view timeline to show how far through a section the user has scrolled — useful for long-form articles or step-by-step tutorials:

/* ── Sticky section header with scroll progress ── */ /* Progress bar inside a sticky section header */.section {  /* This section is our scroll container reference */  view-timeline: --section block;} .section__progress {  position: sticky;  top: 0;  height: 3px;  background: #1D9E75;  transform-origin: left;   animation: grow-width linear both;  animation-timeline: --section;  animation-range: cover 0% cover 100%;  /* Fills up as the section scrolls through the viewport */}

Build Reveal-on-Scroll Without Intersection Observer

The most widely used scroll animation pattern is revealing content as it enters the viewport. JavaScript developers typically handle this with Intersection Observer and a class toggle. CSS scroll-driven animations make this a two-property addition to any element:

/* ── Reveal on scroll: fade in + slide up ── */ @keyframes reveal-up {  from {    opacity: 0;    transform: translateY(32px);  }  to {    opacity: 1;    transform: translateY(0);  }} @media (prefers-reduced-motion: no-preference) {  .reveal-on-scroll {    animation: reveal-up 0.6s ease both;    animation-timeline: view();    animation-range: entry 0% entry 35%;  }} /* No JavaScript. No Intersection Observer.   Add class="reveal-on-scroll" to any element. */

Add class="reveal-on-scroll" to any element in your HTML and it will fade in and slide up as it scrolls into view. The animation-range: entry 0% entry 35% ensures the animation completes quickly (within the first 35% of the entry phase) rather than dragging on as the user keeps scrolling. The both fill mode holds the element at opacity: 0 until it starts entering, then holds it at opacity: 1 after completing.

Staggering multiple elements

To stagger multiple child elements, use animation-delay with a negative value referencing the percentage offset into the view timeline:

/* Stagger card reveals using animation-delay */.cards-grid .card {  animation: reveal-up linear both;  animation-timeline: view();  animation-range: entry 0% entry 35%;} .card:nth-child(1) { animation-delay: calc(0 * -1%); }.card:nth-child(2) { animation-delay: calc(5 * -1%); }.card:nth-child(3) { animation-delay: calc(10 * -1%); }/* Each card starts 5% later in the entry phase */

Note that negative animation-delay with scroll-driven timelines shifts the start point of the animation earlier into the timeline — effectively staggering when each card’s animation begins relative to the scroll position. The easing curve of each reveal matters too — a well-tuned ease-out makes entries feel natural, while linear motion feels robotic. The Cubic Bezier Builder lets you dial in the exact curve and copy the cubic-bezier() value directly into your animation.

Scroll-driven animations also complement CSS transitions for hover states that appear after an element has revealed. If you need to build the transition shorthand for those states, the CSS Transition Generator produces copy-paste output with duration, easing, and delay controls.

Performance & will-change Best Practices

CSS scroll-driven animations that animate transform and opacityrun on the compositor thread — the same thread that handles scrolling. This means they don’t block the main JavaScript thread and don’t cause layout recalculation, giving you genuinely smooth 60fps+ animations even on mid-range mobile devices.

PropertyCompositor thread?Notes
transform✓ YesPreferred for all movement and scale
opacity✓ YesPreferred for fade effects
filterPartialMay promote to its own layer
width / height✗ NoTriggers layout — use scaleX/scaleY instead
background-color✗ NoTriggers paint — use opacity overlay instead
margin / padding✗ NoTriggers layout — use translate instead

When to use will-change

will-change: transform hints to the browser that an element will be animated, prompting it to promote the element to its own compositor layer in advance. For scroll-driven animations, this is generally not needed — the browser handles layer promotion automatically when the animation begins. Overusing will-change consumes GPU memory unnecessarily. Only add it for elements where you observe jank in DevTools performance recordings.

/* Only if you observe jank in DevTools — not by default */.parallax-image {  will-change: transform;  /* add only if measurably needed */}

Accessibility & prefers-reduced-motion

Scroll-driven animations can cause discomfort or nausea for users with vestibular disorders, particularly parallax and large-scale movement effects. The prefers-reduced-motion media query lets you opt out or reduce these effects for users who have indicated a preference for less motion in their operating system settings.

/* Always respect prefers-reduced-motion */ /* Approach 1: Wrap animations */@media (prefers-reduced-motion: no-preference) {  .reveal-card {    animation: fade-in-up linear both;    animation-timeline: view();    animation-range: entry 0% entry 40%;  }} /* Approach 2: Reset in reduced-motion query */.reveal-card {  animation: fade-in-up linear both;  animation-timeline: view();  animation-range: entry 0% entry 40%;} @media (prefers-reduced-motion: reduce) {  .reveal-card {    animation: none;    opacity: 1;    transform: none;  }}

The recommended pattern is to define animations within a @media (prefers-reduced-motion: no-preference) block rather than overriding them in a separate reduce block. This ensures no animation runs at all by default until the user has explicitly indicated they accept motion — a stricter but more accessible approach. The second approach (reset in the reduce query) is acceptable when you want most users to see the animation and only accommodate those who have opted out.

For scroll progress bars and non-movement scroll effects (like changing a header’s background colour as the page scrolls), reduced motion is less critical — the effect is informational rather than decorative. Still, always consider whether a user who has requested less motion would benefit from seeing the effect.

Browser Support

BrowserMin versionNotes
Chrome / Edge115+Full support since July 2023
Firefox132+Stable, unflagged since Oct 2024
Safari18+Full support since Sep 2024
Global (mid-2026)~84%Production-ready with @supports fallback
/* Progressive enhancement with @supports */@supports (animation-timeline: scroll()) {  .progress-bar {    animation: grow-width linear both;    animation-timeline: scroll(root block);  }} /* Without @supports, .progress-bar is simply hidden   or shows as a static element — no broken layout */

For reveal-on-scroll specifically, the graceful degradation is perfect: in unsupported browsers, elements remain at their final opacity: 1state (since the animation never starts), so content is always readable. The animation is purely progressive enhancement — users on older browsers simply don’t see the reveal effect, but they see all the content.

Frequently Asked Questions

What are CSS scroll-driven animations?

CSS scroll-driven animations are animations whose progress is tied to a scroll position rather than elapsed time. Using animation-timeline: scroll() or view(), the browser advances or reverses an animation based on how far a scroll container has scrolled or where an element sits relative to the visible area.

What is animation-timeline in CSS?

animation-timeline is a CSS property that specifies what drives an animation’s progress. The default is auto (time-based). Set it to scroll() to link progress to a scroll container’s scroll position, or view() to link it to an element’s visibility within its scroll container.

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

scroll() tracks how far a scroll container has been scrolled — 0% at the top, 100% at the bottom. Used for global scroll progress indicators. view() tracks when a specific element enters and exits the scroll container’s visible area. Used for per-element reveal, parallax, and exit effects.

How do I make a scroll progress bar with CSS only?

Set position: fixed, transform-origin: left center on the bar element. Define @keyframes from scaleX(0) to scaleX(1). Apply animation: grow-width linear both; animation-timeline: scroll(root block); — no duration needed. Wrap in @media (prefers-reduced-motion: no-preference).

What is animation-range in CSS?

animation-range limits the portion of the scroll or view timeline an animation covers. For example, animation-range: entry 0% entry 30% runs the animation only during the first 30% of an element entering the viewport. Available phase keywords: entry, exit, cover, contain.

Can CSS scroll-driven animations replace Intersection Observer?

For reveal-on-scroll visual effects (fade in, slide up, scale in), yes — animation-timeline: view() is a complete CSS-only replacement. For logic-based side effects (data fetching, class toggling, lazy loading) triggered by scroll position, Intersection Observer is still required.

What browsers support CSS scroll-driven animations in 2026?

Chrome 115+, Edge 115+, Firefox 132+, and Safari 18+. Global browser support is approximately 84% as of mid-2026. Use @supports (animation-timeline: scroll()) for progressive enhancement.

Do CSS scroll-driven animations affect performance?

Animations of transform and opacity run on the compositor thread, making them as smooth as the scroll itself. Avoid animating properties that trigger layout (width, height, margin) — use scaleX/scaleY and translate instead. Do not add will-change preemptively; the browser promotes layers automatically.