CSS scroll-driven animations: a practical guide

The animation-timeline API lets you tie animations directly to scroll position — no JavaScript required. Here's how to use it for scroll entrance effects, and when it actually makes sense to reach for it.

For a long time, scroll-triggered animations required JavaScript. You'd set up an IntersectionObserver, add a class when an element entered the viewport, and write CSS to animate from that class. It worked fine. It was also a fair amount of boilerplate for something that felt like it should be purely presentational — a CSS concern, not a JavaScript one.

The scroll-driven animations API changes that. It lets you connect a CSS animation directly to the scroll position of the page or a scrolling container. The animation plays as you scroll, not when a timer fires or an observer triggers. And for a specific, common use case — elements fading or sliding in as they enter the viewport — it's remarkably clean.

The core idea

A normal CSS animation runs on a time-based timeline. You declare a duration, an easing, a delay, and the browser works through the keyframes over that time period. With scroll-driven animations, you swap the time-based timeline for a scroll-based one. Instead of "play this over 400ms," you're saying "play this as this element moves through a certain region of the viewport."

The property that makes this happen is animation-timeline. The value we care about for entrance effects is view(), which creates a timeline based on the element's own visibility in the viewport. As the element scrolls into view, the animation progresses. As it scrolls out, it reverses.

Building a scroll entrance effect

Here's the full thing — a card that fades up as it enters the viewport:

@keyframes reveal-up {
  from {
    opacity: 0;
    transform: translateY(2rem);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

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

That's it. No JavaScript. No IntersectionObserver. No class toggling. Let's break down the three properties doing the work.

animation-timeline: view()

This replaces the default time-based timeline with a view-based one. The view() function creates a timeline that tracks this specific element's visibility in its nearest scroll container — in most cases, the viewport. The timeline starts at 0% when the element's bottom edge first enters the viewport from below, and reaches 100% when the element's top edge exits the viewport at the top.

animation-range: entry 0% entry 30%

This controls which portion of the element's journey through the viewport drives the animation. entry is the phase when the element is entering the viewport from below. entry 0% is the moment it first appears. entry 30% is when 30% of the entry phase is complete — roughly when the top third of the element is visible.

In plain terms: the reveal animation plays during the first 30% of the element's entrance. By the time one-third of the card is visible, the animation has fully completed. This feels natural — the element finishes revealing itself while it's still clearly in motion, rather than popping into place after the user has already stopped scrolling.

animation: reveal-up linear both

The linear easing is intentional. With time-based animations, you'd use ease-out or a custom easing curve to make the motion feel natural. But with scroll-driven animations, the easing is already provided by the user's scrolling behavior — they slow down and speed up naturally. Adding another easing curve on top of that tends to make things feel over-engineered. linear lets the scroll position drive the feel of the animation directly.

The both fill mode keeps the animation's final state applied even after the animation finishes — so the element stays visible rather than snapping back to its initial hidden state.

Progressive enhancement

Browser support for scroll-driven animations is good but not universal. The right approach is to treat it as a progressive enhancement: elements should be fully visible and functional without it, and the animation is a layer on top for browsers that support it.

The cleanest way to handle this is with @supports:

/* Fallback: always visible */
.card {
  opacity: 1;
  transform: none;
}

/* Enhancement: animate in for supporting browsers */
@supports (animation-timeline: scroll()) {
  .card {
    animation: reveal-up linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 30%;
  }
}

Browsers that don't support the API will see the fallback — elements are visible from the start, no animation. Browsers that do will get the enhanced experience. Nobody gets a broken page.

Respecting motion preferences

Some users have prefers-reduced-motion set in their operating system, either because of vestibular disorders or personal preference. Scroll-driven animations that involve movement — anything with transform or position changes — should be disabled for these users. Fades are generally acceptable; slides and lifts are not.

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

Wrapping the whole thing in prefers-reduced-motion: no-preference means the animation only applies when the user has explicitly said they're fine with motion. It's a small addition that makes the feature genuinely accessible rather than just technically functional.

When to use it

Scroll-driven animations are well-suited to entrance effects — the specific case where you want something to reveal itself as it comes into view. Cards, list items, section headings, images. Things that exist on the page and benefit from a bit of visual arrival.

They're less well-suited to complex orchestrated sequences or anything that needs precise timing relative to other elements. For those cases, an IntersectionObserver with JavaScript still gives you more control over exactly when and how things trigger.

The other thing worth noting: scroll-driven animations only animate as the user scrolls. If someone loads the page and doesn't scroll, elements below the fold stay in their initial state. That's usually fine for entrance effects, but it's worth keeping in mind if you're using this for anything that needs to play on load rather than on scroll.

What I used it for on this site

Every card grid on this site — the project cards on the Work page, the journalism items on the Writing page, the skills groups on About — uses exactly this pattern. The same four lines of CSS, wrapped in @supports and prefers-reduced-motion. No JavaScript involved anywhere in the scroll reveal behavior.

The whole approach feels right for a site that's trying to demonstrate what modern CSS can do. Scroll entrance effects used to be a JavaScript problem. They don't have to be anymore.