CSS November 7, 2026

CSS scroll-driven animations: animate on scroll without JavaScript

CSS finally has a native way to connect animations to scrolling. The feature is called scroll-driven animations, and it solves another one of those frontend problems we’ve been handling with JavaScript, scroll event listeners, and extra calculations for years.

Think about reading progress bars, elements that fade in as you scroll, horizontal carousel indicators, or images that change as they move through the viewport. These are all common effects, but implementing them usually meant tracking the scroll position, calculating progress, and updating styles manually.

It works, but the browser already knows where we are on the page. We should not need JavaScript just to turn that information into an animation.

The problem

Regular CSS animations are based on time. You define some keyframes, set a duration, and the browser animates between them.

.box {
  animation: fade-in 500ms ease;
}

That works when you want an animation to start and finish after a certain amount of time. But what if you want the animation to follow the user’s scroll position instead?

A common example is a reading progress bar. As someone scrolls down an article, a small bar at the top of the page grows to show how far they have read.

The usual JavaScript solution looks something like this:

const progressBar = document.querySelector(".progress-bar");

function updateProgress() {
  const scrollable =
    document.documentElement.scrollHeight -
    window.innerHeight;

  const progress = scrollable > 0
    ? window.scrollY / scrollable
    : 0;

  progressBar.style.transform = `scaleX(${progress})`;
}

window.addEventListener("scroll", updateProgress);
window.addEventListener("resize", updateProgress);

updateProgress();

This is not particularly complicated JavaScript, but it’s still JavaScript that needs to run whenever the user scrolls.

And if you want more animations, you might end up adding more listeners, measuring elements, calculating when they enter the viewport, or using Intersection Observer.

CSS scroll-driven animations give the browser a way to handle the animation progress itself.

The basic idea

Normally, CSS animations use the document’s time-based timeline. An animation starts, time passes, and the animation progresses from its first keyframe to its last.

Scroll-driven animations work differently.

Instead of using time, they use scrolling to determine the animation’s progress.

The important property is:

animation-timeline: scroll();

This tells the browser to use a scroll progress timeline instead of the normal time-based timeline.

For example:

.box {
  animation: grow linear both;
  animation-timeline: scroll();
}

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

  to {
    transform: scaleX(1);
  }
}

The box now grows as its nearest scroll container is scrolled, rather than growing over a fixed number of milliseconds.

When scrolling stops, the animation stops progressing. When you scroll backwards, the animation reverses.

There are two main types of scroll-driven timelines:

Both are useful, but for different problems.

Let’s start with the simpler one.

A reading progress bar

A reading progress bar is probably one of the clearest examples of what scroll-driven animations can do.

We want a thin bar fixed to the top of the page. At the beginning of the article, it should be empty. As the user scrolls down, it should grow. At the bottom, it should fill the available width.

First, the HTML:

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

Then the CSS:

.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 4px;
  background: #6366f1;
  transform-origin: left;
  z-index: 100;

  animation: grow linear both;
  animation-timeline: scroll(root block);
}

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

  to {
    transform: scaleX(1);
  }
}

That’s the whole effect.

The key part is:

animation-timeline: scroll(root block);

The root value tells the browser to track scrolling on the root scroll container, which is normally the page itself.

The block value specifies the block axis. On a typical page with horizontal text, that means vertical scrolling.

We also use transform: scaleX() instead of animating the element’s width. Transforms are generally a better choice for this kind of animation because they can avoid repeated layout calculations.

Notice that we didn’t specify an animation duration. With a scroll-driven timeline, the default auto duration covers the timeline’s range.

The animation is controlled by scrolling, not by how many seconds have passed.

No scroll listeners, no JavaScript calculations. The browser already knows how much of the page has been scrolled, so it can update the animation directly.

Choosing the scroll container

There is one important detail about scroll().

By default, it uses the nearest ancestor scroll container. That’s not necessarily the page.

These are some useful variations:

animation-timeline: scroll();
animation-timeline: scroll(root);
animation-timeline: scroll(self);
animation-timeline: scroll(nearest inline);

scroll() uses the nearest ancestor scroll container and the block axis by default.

scroll(root) tracks the root scroll container.

scroll(self) tracks scrolling on the animated element itself, which can be useful when the element is also a scroll container.

And scroll(nearest inline) tracks scrolling along the inline axis of the nearest scroll container, usually horizontal scrolling in left-to-right layouts.

This matters when you have nested scrolling areas.

For example, imagine a sidebar with its own scrollbar. If you use scroll() on an element inside that sidebar, the animation may follow the sidebar’s scroll position rather than the page’s.

Sometimes that’s exactly what you want. Sometimes it isn’t.

I would usually be explicit about the scroll container, especially when the animation is connected to the entire page.

Also, the selected container needs to have an actual scrollable range. If there’s nothing to scroll along the chosen axis, the scroll progress timeline is inactive.

Animating elements as they enter the viewport

A reading progress bar follows the scroll position of the page. But sometimes you don’t care how far the whole page has been scrolled.

You care about one particular element.

For example, you might want a card to fade in and move slightly upwards as it enters the viewport.

This is where view() becomes useful.

.card {
  animation: reveal linear both;
  animation-timeline: view();
}

Unlike scroll(), which tracks the progress of a scroll container, view() creates a timeline based on an element’s visibility inside its nearest scroll container.

The animation progresses as the element moves through the visible area.

Let’s define the animation:

@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(24px);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

And apply it:

.card {
  animation: reveal linear both;
  animation-timeline: view();
}

This works, but there’s a small problem.

By default, the view timeline covers the element’s journey through the scrollport, from entering to leaving it. That means the animation may continue progressing long after the card has become visible.

For a simple reveal, we usually want the animation to finish much earlier.

That’s where animation-range comes in.

Controlling the animation range

The animation-range property lets us choose which part of a scroll-driven timeline should control the animation.

For our card reveal, we can use:

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

The entry range represents the period when the element is entering the scrollport.

entry 0% is the beginning of that entry, and entry 100% is the end of the entry phase.

So instead of spreading the animation across the element’s entire journey through the viewport, we limit it to the entering phase.

We can also use other ranges:

animation-range: entry;
animation-range: exit;
animation-range: cover;
animation-range: entry 0% cover 40%;

entry covers the entry phase.

exit covers the period when the element leaves the scrollport.

cover follows the element from the beginning of its entry until it has completely exited.

And entry 0% cover 40% lets the animation start when the element begins entering and finish at 40% of its cover range.

The percentages are relative to the chosen timeline range, not simply percentages of the element’s height.

You can use these ranges to control when an image starts fading in, when a card finishes moving, or how long an effect should remain active.

One detail worth remembering is that these are scroll-driven animations, not one-time animations.

If you scroll backwards, the animation also moves backwards.

That can be useful for some effects, but it is not always what you want. If an element should animate once and stay revealed forever, you may still want an intersection-based trigger or another solution.

A horizontal scroll indicator

Scroll-driven animations are not limited to vertical scrolling.

Another useful case is a horizontally scrollable list of cards, something you might use for featured articles, products, testimonials, or projects.

You can already build the scrolling behavior with CSS using overflow-x: auto and scroll-snap-type.

Now you can also add a progress indicator that follows the horizontal scroll position.

Let’s start with the HTML:

<div class="carousel">
  <div
    class="carousel-track"
    tabindex="0"
    aria-label="Featured projects"
  >
    <article class="card">Project one</article>
    <article class="card">Project two</article>
    <article class="card">Project three</article>
    <article class="card">Project four</article>
  </div>

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

The cards live inside the scrollable track, while the progress bar sits underneath it.

First, some basic scrolling styles:

.carousel-track {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}

.carousel-track .card {
  flex: 0 0 min(80%, 18rem);
  scroll-snap-align: start;
}

That gives us a horizontally scrollable row of cards.

To connect the progress bar to the track, we can use a named scroll timeline:

.carousel {
  timeline-scope: --carousel-scroll;
}

.carousel-track {
  scroll-timeline: --carousel-scroll inline;
}

Like CSS custom properties, timeline names begin with two dashes.

The scroll-timeline declaration names the scroll container’s timeline and specifies the inline axis.

The timeline-scope declaration is important because the progress bar is not a descendant of the scrolling element. Both elements are inside .carousel, so we extend the named timeline’s scope to their shared parent.

Now we can use that timeline for the progress bar:

.carousel-progress {
  height: 4px;
  background: #e5e7eb;
}

.carousel-progress span {
  display: block;
  height: 100%;
  background: #6366f1;
  transform-origin: left;

  animation: grow linear both;
  animation-timeline: --carousel-scroll;
}

We can reuse the same grow keyframes from the reading progress bar.

As the user scrolls horizontally, the indicator grows. Scroll back to the beginning, and it shrinks again.

I like this example because the scrolling and the progress indicator are both handled by CSS.

The browser knows how wide the scrollable area is, how much content is hidden, and where the current scroll position is. We don’t need to measure any of that ourselves.

Why the order of properties matters

There is one small detail that can make scroll-driven animations confusing.

The animation shorthand resets animation-timeline to its default value.

So this does not work as intended:

.card {
  animation-timeline: view();
  animation: reveal linear both;
}

The second declaration resets the timeline you just assigned.

Instead, write:

.card {
  animation: reveal linear both;
  animation-timeline: view();
}

The same idea applies to animation-range, which is also reset by the animation shorthand.

I would keep these properties together and always write them in this order:

.card {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry;
}

It’s a small thing, but it can save you some time when an animation suddenly stops following the scroll position.

A practical default

For most projects, I would not start by adding scroll-driven animations to every element.

I would create a small reusable class for the elements that actually benefit from the effect.

For example:

@keyframes scroll-reveal {
  from {
    opacity: 0;
    transform: translateY(1.5rem);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

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

Then apply it only where it makes sense:

<section class="scroll-reveal">
  <h2>Featured projects</h2>
  <p>Some content that appears as you scroll.</p>
</section>

For a reading progress bar, I would use scroll(root block).

For individual elements entering the viewport, I would use view() with a deliberate animation-range.

For a separate element that needs to follow a particular scroll container, I would use a named timeline.

There is no need to make every animation more complicated than it needs to be.

And just like with other modern CSS features, the basic example is not quite the whole production-ready version.

Progressive enhancement

Scroll-driven animations are now available in current versions of the major browser engines, including Chrome, Edge, Safari, and Firefox.

But this is still a relatively new feature, and older browsers may not support it.

You can check the full scroll-driven animations documentation on MDN and current browser support on Can I Use.

I would treat these animations as progressive enhancement.

For decorative effects, the fallback can simply be no animation.

The content should still be visible and usable.

A defensive version of the reveal effect could look like this:

@keyframes scroll-reveal {
  from {
    opacity: 0;
    transform: translateY(1.5rem);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

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

The @supports condition checks for the timeline and range features we need.

And the prefers-reduced-motion media query makes sure we only apply the decorative reveal animation when the user has not requested reduced motion.

Outside this block, .scroll-reveal has no animation or hidden starting state. The content remains visible.

This is important. If you put opacity: 0 on the element by default and only make it visible through an unsupported animation, users on older browsers might never see the content.

A browser without scroll-driven animation support should not have to work harder to read your page.

Respecting reduced motion

Scroll-driven animations can feel more natural than time-based animations because they follow the user’s interaction.

But that does not mean they are automatically accessible.

Large parallax movements, aggressive scaling, and elements constantly moving in and out of view can still be uncomfortable or distracting.

For decorative effects, I would use:

@media (prefers-reduced-motion: no-preference) {
  /* Scroll-driven animations */
}

If an animation communicates useful information, consider whether there is a calmer way to provide that information instead of removing it completely.

For example, a reading progress indicator can be useful. A large image moving in the opposite direction of scrolling is probably not essential.

The goal should not be to animate everything just because CSS makes it easy.

When not to use it

Scroll-driven animations are useful, but they don’t replace every JavaScript animation or scroll interaction.

If you need an animation to run only once, remember its completed state, coordinate with application state, or trigger a more complex sequence of interactions, JavaScript may still be the better option.

They are also not a reason to build complicated scroll effects everywhere.

Too many moving elements can make a page harder to read. Heavy animation of layout-related properties can affect performance. And an interface that depends on animations to reveal essential information can become unusable when those animations are unavailable.

I would generally prefer transform and opacity for scroll-driven effects, keep the movement subtle, and test the behavior on smaller screens and with reduced-motion preferences enabled.

There is also a difference between scroll-driven and scroll-triggered animations.

Scroll-driven animations follow scroll progress directly. They can move forwards, backwards, or stop depending on the user’s scrolling.

A scroll-triggered animation starts when a condition is met, then runs according to time. That is a different behavior, and sometimes it’s the one you actually want.

Use scroll-driven animations when the effect should genuinely be connected to scrolling.

Not just because an element happens to appear somewhere down the page.

To recap

CSS scroll-driven animations let us use scroll position instead of time to control the progress of an animation.

The two most useful functions are:

animation-timeline: scroll();
animation-timeline: view();

scroll() follows the progress of a scroll container.

view() follows an element as it moves through the visible area.

animation-range controls which part of that timeline should be used, and named timelines let us connect animations to specific scroll containers.

For me, the most useful examples are reading progress bars, subtle reveal effects, and horizontal scroll indicators.

Like CSS field-sizing, CSS anchor positioning, and CSS @starting-style, this is another example of the browser taking over work that previously required JavaScript.

It doesn’t mean JavaScript is no longer needed for animations. It means we can stop manually tracking scroll positions for effects that CSS can now handle on its own.

And the best part is that these animations follow something the browser already understands: how the user is scrolling.

That’s a pretty good reason to let CSS do the work.

Keep in touch

Occasional emails about new articles and the things I’m building.