# Scroll-driven CSS animations

> Animate elements from scroll position with animation-timeline scroll() and view(). No JavaScript needed. Check current browser support before shipping.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-23 | Updated: 2026-08-21 | Topics: [CSS](https://flaviocopes.com/tags/css/) | Canonical: https://flaviocopes.com/css-scroll-driven-animations/

Scroll-driven animations connect animation progress to a scroll position.

Instead of saying “run this animation for 500 milliseconds,” you say “run it from the top to the bottom of this scroller” or “run it while this element enters the viewport.”

The browser updates the animation as the user scrolls. You do not need a scroll event listener or repeated JavaScript calculations.

Scroll-driven animations build on normal [CSS animations](https://flaviocopes.com/css-animations/). The keyframes stay the same. We replace the time-based animation timeline with a scroll-based one.

The [free CSS course](https://flaviocopes.com/courses/css/) covers keyframes, transforms, motion preferences, and responsive layout if you need those foundations first.

## Two kinds of scroll timeline

CSS provides two useful timeline functions:

- `scroll()` follows the scroll position of a container
- `view()` follows an element as it moves through a scrollport

Use `scroll()` for reading progress bars, horizontal carousels, or effects tied to an entire page.

Use `view()` for a card that reveals as it enters the viewport, an image that changes while visible, or a section indicator.

These are **scroll-driven** animations. They are different from scroll-triggered animations that start at a certain point and then continue according to time.

## Build a reading progress bar

Add one element near the start of the page:

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

Position it at the top of the viewport:

```css
.reading-progress {
  position: fixed;
  z-index: 10;
  top: 0;
  left: 0;
  width: 100%;
  height: 4px;
  background: royalblue;
  transform: scaleX(0);
  transform-origin: left;
}
```

Now define the animation:

```css
@keyframes grow-progress {
  from {
    transform: scaleX(0);
  }

  to {
    transform: scaleX(1);
  }
}
```

Attach it to the root scroller:

```css
.reading-progress {
  animation: grow-progress 1ms linear;
  animation-timeline: scroll(root block);
}
```

`root` means the document scroller. `block` means its block axis, which is vertical in a typical left-to-right page.

At the top, the timeline is at 0%. At the bottom, it reaches 100%. The scale animation follows that progress.

The `1ms` duration does not make the scroll effect finish in one millisecond. Scroll position controls the progress. A small non-zero duration keeps the animation working in Firefox and provides a harmless fallback in browsers without scroll timelines.

Notice that `animation-timeline` comes after the `animation` shorthand. The shorthand resets the timeline to `auto`, so reversing those declarations would restore the normal time-based document timeline.

Animating `transform` is better than changing `width` on every frame. The browser can usually update a transform without recalculating the page layout.

Try it here. Scroll inside the frame and watch the blue bar:

<iframe
  src="https://flaviocopes.com/demos/css-scroll-driven-animations/reading-progress.html"
  title="Live reading progress bar example"
  loading="lazy"
  style="display: block; width: 100%; height: 22rem; border: 1px solid #999; background: white;"
></iframe>

## Use the nearest scroll container

`scroll()` can follow a smaller scrolling element.

```html
<section class="activity-panel">
  <div class="panel-progress"></div>
  <div class="activity-list">...</div>
</section>
```

Make the panel scroll:

```css
.activity-panel {
  position: relative;
  max-height: 20rem;
  overflow-y: auto;
}
```

Attach the animation to the nearest scroller:

```css
.panel-progress {
  position: sticky;
  top: 0;
  height: 3px;
  background: seagreen;
  transform-origin: left;
  animation: grow-progress 1ms linear;
  animation-timeline: scroll(nearest block);
}
```

`nearest` asks the browser to find the closest ancestor that scrolls. This keeps the component independent from the page.

## Reveal an element with view()

A view timeline tracks one element as it passes through its nearest scrollport.

Define a small reveal:

```css
@keyframes reveal-card {
  from {
    opacity: 0;
    transform: translateY(2rem);
  }

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

Attach each card to its own view timeline:

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

`view(block)` watches the card on the block axis. The timeline starts before the card enters and ends after it leaves.

`animation-range` limits the part we use. Here the animation runs only during the `entry` range, from the first intersection until the card is fully inside the scrollport.

Scroll inside this frame. Each card reveals as it enters:

<iframe
  src="https://flaviocopes.com/demos/css-scroll-driven-animations/reveal-cards.html"
  title="Live view timeline card reveal example"
  loading="lazy"
  style="display: block; width: 100%; height: 24rem; border: 1px solid #999; background: white;"
></iframe>

## Understand the useful ranges

A view timeline contains named ranges. The most useful are:

- `entry` while the element enters
- `contain` while the element fits entirely inside
- `exit` while the element leaves
- `cover` across the full visible journey

Reveal while entering, then fade while exiting:

```css
@keyframes appear-and-leave {
  entry 0% {
    opacity: 0;
    transform: scale(0.9);
  }

  entry 100%, exit 0% {
    opacity: 1;
    transform: scale(1);
  }

  exit 100% {
    opacity: 0;
    transform: scale(0.9);
  }
}

.photo {
  animation: appear-and-leave 1ms linear both;
  animation-timeline: view();
}
```

Named range keyframes let the animation describe the element's whole trip. For a first implementation, a simple `animation-range` is easier to debug.

## Create a named timeline

Anonymous `scroll()` and `view()` timelines cover most cases. Use a named timeline when the scroller and animated element are not in a convenient ancestor relationship.

Suppose a horizontal gallery and its indicator are siblings:

```html
<div class="gallery-shell">
  <div class="gallery">...</div>
  <div class="gallery-indicator"></div>
</div>
```

Name a scroll timeline on the container:

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

Named timelines are normally available to descendants of the timeline source. The indicator is a sibling, so extend the timeline's scope from their shared parent:

```css
.gallery-shell {
  timeline-scope: --gallery-scroll;
}
```

Now use that name from the indicator:

```css
.gallery-indicator {
  animation: grow-progress 1ms linear;
  animation-timeline: --gallery-scroll;
}
```

Custom timeline names start with `--`, like custom properties.

If the animated element is already inside the scroller, you usually do not need `timeline-scope`. Start with an anonymous `scroll()` timeline and introduce a name only when the relationship requires it.

Drag the gallery sideways. The green indicator follows its scroll position:

<iframe
  src="https://flaviocopes.com/demos/css-scroll-driven-animations/horizontal-gallery.html"
  title="Live named scroll timeline gallery example"
  loading="lazy"
  style="display: block; width: 100%; height: 25rem; border: 1px solid #999; background: white;"
></iframe>

## Choose the correct axis

The logical axes adapt to writing mode:

- `block` is the direction blocks flow, usually vertical
- `inline` is the direction text flows, usually horizontal

The physical axes do not adapt:

- `y` follows vertical scrolling
- `x` follows horizontal scrolling

For a typical article progress bar, use `block`:

```css
animation-timeline: scroll(root block);
```

For a horizontal gallery, use `inline`:

```css
animation-timeline: scroll(nearest inline);
```

I prefer logical axes for components. They keep working when the document writing mode changes.

## Adjust when a view animation starts

`view()` can inset the scrollport used by the timeline.

```css
.card {
  animation: reveal-card 1ms linear both;
  animation-timeline: view(block 15% 15%);
}
```

The two `15%` values move the effective start and end edges inward. The animation begins after the card crosses the inset edge and finishes before it reaches the other one.

You can also keep the default view timeline and select a range:

```css
.card {
  animation: reveal-card 1ms linear both;
  animation-timeline: view();
  animation-range: entry 20% cover 40%;
}
```

This starts partway through entry and finishes early in the full cover journey.

Use one technique at first. Combining insets, named ranges, and range keyframes too early makes the effect harder to debug.

## Progressive enhancement

The content must remain usable without the animation.

Set the readable state first:

```css
.card {
  opacity: 1;
  transform: none;
}
```

Add the scroll-driven behavior inside `@supports`:

```css
@supports (animation-timeline: view()) {
  .card {
    animation: reveal-card 1ms linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 100%;
  }
}
```

Unsupported browsers ignore the block. The cards remain visible.

Do not set `opacity: 0` outside the feature query. Otherwise an unsupported browser can hide the content permanently.

## Debug the timeline before the effect

When an animation does nothing, simplify it to two obvious states:

```css
@keyframes debug-progress {
  from {
    background: red;
    transform: scale(0.5);
  }

  to {
    background: lime;
    transform: scale(1);
  }
}
```

Attach that animation to the same timeline. If it still does not move, check the timeline rather than the final design.

Work through these questions:

1. Does the chosen element actually scroll?
2. Did `animation-timeline` come after the `animation` shorthand?
3. Is the axis correct?
4. Can the animated element see the named timeline?
5. Does the view subject travel far enough through the scrollport?

Browser developer tools can inspect CSS animations and grid the current computed properties. Check whether `animation-timeline` resolved to your scroll or view timeline instead of `auto`.

## Respect reduced motion

Scroll-linked movement can be uncomfortable even though the user controls the scroll position.

Disable it when the user asks for reduced motion:

```css
@media (prefers-reduced-motion: reduce) {
  .reading-progress,
  .card,
  .photo {
    animation: none;
  }
}
```

A progress bar that only changes scale may be acceptable in some designs, but large parallax and reveal movements should disappear.

## Common mistakes

The first mistake is putting the shorthand after the timeline:

```css
.card {
  animation-timeline: view();
  animation: reveal-card 1ms linear both;
}
```

The `animation` line resets the timeline. Put `animation-timeline` last.

The second mistake is selecting an element that does not scroll. `scroll(nearest)` can resolve to a scroll container whose content has no overflow, leaving the timeline with no useful progress.

The third is using `view()` for an element that barely moves through the scrollport. A tall element can make ranges such as `contain` very short or impossible because it never fits fully inside.

The fourth is animating layout-heavy properties. Prefer `transform` and `opacity` for frequent visual updates. Test the effect on a slower device when it paints large shadows, filters, or backgrounds.

The fifth is hiding content in the initial state without a fallback. Progressive enhancement must start from readable content.

## When CSS is enough

Use CSS when scroll position directly controls a visual property. Progress bars, reveals, scale changes, and subtle parallax are good fits.

Use JavaScript when scrolling must cause application work: load data, update the URL, start media, or send analytics. An animation timeline does not replace application logic.

Scroll-driven animations work in current browsers, but not every part has identical support. Check the official [MDN scroll-driven animations guide](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations) for the current details and keep the fallback visible.

If you already know [CSS animations](https://flaviocopes.com/css-animations/) and [CSS transitions](https://flaviocopes.com/css-transitions/), the new part is only the timeline. Start with a reading progress bar, then try a single `view()` reveal.
