CSS Tutorials 5-8 minutes

Scroll-Linked Animations in Pure CSS: A 2026 Guide to Scroll-Driven Animations

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Share:
Scroll-Linked Animations in Pure CSS: A 2026 Guide to Scroll-Driven Animations
Image generated with AI

Scroll stopped being an event you listen to and became the animation's timeline. With pure CSS you can build the reading progress bar, the block reveal and a smooth parallax with zero lines of JavaScript.

What Scroll-Driven Animations Are (and What They Are Not)

A normal CSS animation advances with the clock: it starts, runs for its duration and ends. A scroll-driven animation advances with the scroll position. The change looks small and changes everything: the listener that computed percentages every frame goes away, and so does the library that did that job.

Duration Stops Being Time: Your Scroll Drives the Progress

When an animation has an automatic duration and a scroll timeline, 0% of the journey is where scrolling starts and 100% is the end. There are no milliseconds to tune: the user sets the pace with a finger or a wheel.

Two Ways to Travel: scroll() and view()

scroll() follows the progress of a scroll container, including the whole page with scroll(root). view() follows a specific element as it crosses the visible area. The first is for effects tied to the page; the second, for reveals.

What This Really Replaces: IntersectionObserver and Scroll Libraries

The classic way to reveal blocks was IntersectionObserver: watch whether an element entered the viewport and add or remove a class. It works, but it lives in JavaScript and adds main-thread work. Animation libraries cover more cases, at the cost of weight and an extra layer of abstraction. Scroll-driven animations cover much of both with stylesheets alone.

Real Browser Support in September 2026

Chromium 115+ and Safari 26: Unflagged and Shipping

Chromium (Chrome, Edge, Opera, Brave) has supported animation-timeline since version 115, released in July 2023. Safari added it in version 26. In caniuse data, global support for animation-timeline sits around 82-83%.

Firefox Is Still Behind (about:config Flag), So It Is Not Baseline

Firefox does not ship them enabled by default. You can try them by turning on layout.css.scroll-driven-animations.enabled in about:config, and its implementation is partial. That is why you should not call them Baseline: technically they are not.

Progressive Enhancement: @supports with animation-timeline and the animation-range Caveat

Not being Baseline does not mean you cannot use them. It means you ship them as progressive enhancement, inside an @supports block, and that if the browser does not understand them the block is ignored and everything keeps working. One detail many miss: check for animation-range too, because some engines accept the timeline but not the range.

@supports (animation-timeline: view()) and (animation-range: entry) {
  .reveal {
    animation: fade auto linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 40%;
  }
}

The Basics: animation-timeline and animation-range

Two properties do the work. animation-timeline says where the progress comes from; animation-range says which slice of that progress the animation occupies.

Declaring the Animation with animation-duration: auto and fill-mode both

The duration has to be auto: that way the animation is not measured in seconds but in the timeline's journey. fill-mode: both holds the starting state before the range and the final state after it.

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

.reveal {
  animation: fade auto linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 40%;
}

The Named Ranges: cover, contain, entry and exit

View timelines come with four names. entry is the moment the element enters the visible area. contain is when it is fully inside. exit is when it starts leaving. cover spans the whole journey and is the default value.

Percentages and Crossed Ranges: entry 25% cover 50%

Ranges can be combined and tuned with percentages. entry 25% cover 50% means the animation starts once the element is 25% in and ends halfway through the full journey. It is how you control precisely when the effect shows up.

Named Timelines: scroll-timeline-name, view-timeline-name and timeline-scope

You can also name a timeline and link elements that are not direct relatives. Declare it with scroll-timeline-name or view-timeline-name on the element that produces it, consume it with animation-timeline: --name on the element being animated, and use timeline-scope to widen the name's reach when the elements sit in different branches of the tree.

.gallery {
  scroll-timeline-name: --carousel;
  scroll-timeline-axis: inline;
  overflow-x: auto;
}

.card {
  animation: fade auto linear both;
  animation-timeline: --carousel;
}

Pattern 1: A Reading Progress Bar

It is the highest-value example and the shortest one.

From the Root Scroller to the Bar's Width

The bar is a fixed element at the top, and its horizontal scale follows the page's scroll progress. With scroll(root) you need no special container.

Animate transform: scaleX Instead of width (and Why)

The bar grows with transform: scaleX(), not with width. The reason is performance: transform is handled by the compositor without recalculating the page layout; width forces the browser to redo layout on every frame.

.progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 4px;
  transform-origin: 0 50%;
  background: #38bdf8;
  animation: grow auto linear both;
  animation-timeline: scroll(root);
}

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

Keeping It Visible and Tasteful Where It Cannot Animate

If the browser lacks timeline support, the bar would be stuck at the keyframe's final state or simply invisible. Give it a sensible base state and wrap the effect in @supports, so Firefox does not show a frozen bar across the screen.

Pattern 2: Revealing Blocks as They Enter the Viewport

view() with animation-range: entry 0% entry 40%

This is the direct replacement for IntersectionObserver. The block appears as it enters the viewport and finishes once it has travelled the first 40% of its entry range.

.reveal {
  animation: fade auto linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 40%;
}

Content Must Never Disappear When Support Is Missing

Here is the most expensive mistake: leaving the block at opacity: 0 in your base CSS. If the browser does not support timelines, the content never shows up. The animation must live only inside @supports, with the visible state as the base.

Staggering Several Cards Without JavaScript

To keep several cards from entering all at once, just give each one a different animation-range. Each element has its own view timeline, so the stagger comes for free without counting indexes in JavaScript. The later the range starts, the later the card appears.

Pattern 3: Parallax and a Header That Shrinks

Layers Moving at Different Speeds with scroll()

Parallax is two layers sharing the root scroll timeline while travelling different distances. The larger the travel, the faster the layer appears to move.

.layer-far {
  animation: rise-slow auto linear both;
  animation-timeline: scroll(root);
}

.layer-near {
  animation: rise-fast auto linear both;
  animation-timeline: scroll(root);
}

@keyframes rise-slow { to { transform: translateY(-40px); } }
@keyframes rise-fast { to { transform: translateY(-140px); } }

A Sticky Header That Changes Size with view()

A fixed header can shrink over the first pixels of scroll by using a bounded range, so the effect finishes early instead of depending on the whole page.

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

@keyframes compact {
  from { padding-block: 1.25rem; }
  to   { padding-block: 0.5rem; }
}

Mind this last one: animating padding-block changes the element's size and triggers layout recalculation. If you want maximum performance, animate a transform: scale() on the header's inner content and keep the height fixed.

Performance: When This Is Fast and When It Is Not

Compositor-Friendly Animations vs. Animations That Trigger Layout

If the animation touches transform or opacity, the browser handles it on the compositor thread and the main thread stays free. If it touches properties that affect layout or paint, such as width, height, padding or top, the work goes back to the main thread and that is where the jank shows up.

What It Shows Up as in Metrics Like INP

Interaction to Next Paint measures how quickly the page responds after an interaction. There are migration reports showing clear INP gains when moving from scroll listeners to declarative timelines, but those are third-party measurements on specific sites, not a guarantee for any project.

Accessibility and Reduced Motion

prefers-reduced-motion Applied to Scroll Timelines

Some users configure their system to reduce motion, and scroll-driven animations are exactly the kind that bothers them most. You neutralise them with a media query that removes the animation and leaves the final state.

@media (prefers-reduced-motion: reduce) {
  .reveal,
  .progress,
  .layer-near {
    animation: none;
  }
}

When a Time-Based Animation Beats a Scroll-Driven One

Not everything is scroll. A scroll-driven animation is ideal for scrubbing progress with the movement, but if the effect should play once with its own timing as it enters the viewport, the scrubbing feel is odd and a time-based animation fits better.

Common Mistakes That Make It "Not Work"

No Scroll Container, No Timeline

If the container a timeline points at has no overflow and cannot scroll, there is no progress to follow. An overflow: hidden where you expected a scroller is the usual culprit.

Forgetting the Time-Based Fallback and Hiding Your Content

We said it already, but it is the most repeated mistake: visible state by default and the animation inside @supports. Otherwise, on browsers without support, the content stays hidden for good.

Elements That Never Cross the Scrollport

If an element is always visible or never enters the viewport, its view range never advances and the animation never shows. This usually happens with very tall blocks or with elements already inside the viewport on load.

A Complete Example: From Progress Bar to Card Reveal

Put the previous pieces together: a bar with scroll(root), a reveal with view() and per-card ranges, all wrapped in @supports with the final state as the base. It is about twenty lines of CSS and no JavaScript file at all.

@supports (animation-timeline: view()) and (animation-range: entry) {
  .progress { animation-timeline: scroll(root); }
  .reveal   { animation-timeline: view(); animation-range: entry 0% entry 40%; }
}

Conclusion

Scroll-driven animations do not replace every animation library, but they do cover the three most requested effects without adding dependencies. With @supports you can ship them today without risk, and once Firefox enables its implementation the same CSS will work unchanged. If you are interested in the rest of modern CSS you can already use in production, see the guide to the View Transitions API without libraries, the one on light and effective CSS animations and the classic on smooth scrolling for anchors with JavaScript.

Categories