Skip to content
All articles

Scroll-Driven CSS Animations Without Stranding Your Content

6 min read

Reveal-on-scroll used to mean an IntersectionObserver, a class toggled on an element, and a small pile of state to make sure it didn't fire twice or fire early. CSS can now do it natively with animation-timeline: you write an ordinary keyframe animation and tell it to be driven by scroll position instead of by time.

It's genuinely better — it runs off the main thread, there's no observer to leak, and the whole effect is a few lines of CSS. It also has a failure mode that is far worse than the JavaScript version's, and it's one you will not see in your own browser.

The two timelines

view() ties the animation to an element's own visibility: 0% as it enters the scrollport, 100% as it leaves. This is what you want for revealing a section as it comes into view.

scroll() ties it to a scroll container's position instead — scroll(root block) being the whole document, top to bottom. This is what you want for a reading-progress bar, or anything that should track overall position rather than one element.

Both are used the same way: write @keyframes, apply the animation, set animation-timeline, and — importantly — animation-fill-mode: both so the element holds the start state before the timeline begins and the end state after it finishes.

The failure mode

MDN is explicit that animation-timeline is not Baseline — it does not work in some of the most widely used browsers. Support landed in Chrome and Edge some time ago and has been arriving in Safari; Firefox has trailed.

Now the important part, and the reason this deserves an article rather than a snippet. A browser that doesn't support animation-timeline does not skip your animation. It ignores the property and falls back to the default value, auto — which is a time-based timeline.

Follow that through:

  • You write @keyframes reveal { from { opacity: 0 } to { opacity: 1 } }
  • In a supporting browser it's driven by scroll — the section fades in as it arrives. Exactly what you wanted.
  • In a non-supporting browser it runs on a normal time-based clock, immediately, on page load. Every section on the page fades in at once, over whatever duration you set, whether or not it's anywhere near the viewport.

That's the benign version. The dangerous version is any animation whose resting state is invisible — a fade-out on exit, a reverse direction, an animation-play-state: paused, a keyframe set that ends at opacity: 0. On the fallback timeline that animation runs to completion on load, and with fill-mode holding the end state, the content is now permanently invisible. Not degraded — gone. The page looks broken and the browser reports no error.

You will not catch this in development, because your browser supports the feature.

The fix: gate it, and start from visible

Two rules, and they work together.

First, put every scroll-driven rule behind a feature query. @supports (animation-timeline: view()) { … } means a browser without support never sees the animation properties at all, so there is no fallback timeline to run. It gets the plain, un-animated page — which is the correct degradation, and is what "progressive enhancement" actually means here.

Second, author so the un-animated state is the readable one. Don't set opacity: 0 in your base CSS and rely on the animation to bring it back. Leave the element visible by default, and let the animation inside the @supports block be the thing that adds the movement. Then the worst case in any browser, under any failure, is that content appears without a flourish.

The two rules cover different failures. The feature query handles "browser doesn't support it". Authoring from a visible base handles everything else — a stylesheet that didn't load, a rule that got overridden, a bug in your keyframes. If the only thing keeping your text on screen is an animation completing correctly, you've made readability conditional on an effect.

Respect reduced motion, properly

Scroll-driven animation is exactly the category of movement that triggers vestibular problems for some people, and the operating-system preference for reduced motion is how they tell you.

Nest the whole thing inside @media (prefers-reduced-motion: no-preference) — a positive condition rather than a negative one. Wrapping the animations in "no preference" means the default, for anyone whose preference you can't read, is the calm version. Writing it as @media not (prefers-reduced-motion: reduce) inverts the default in a way that's easy to get subtly wrong.

Combined, the structure is: reduced-motion media query on the outside, feature query inside it, animations inside that. Three layers, and each one has a different job.

What it costs

Very little, which is the appeal. No observer, no scroll listener, no state, no cleanup on unmount, nothing to get wrong on a route change. The animation is declarative and the browser runs it off the main thread, so it doesn't compete with your JavaScript — which matters on exactly the low-end devices where a scroll handler hurts most.

The catch is the one above, and it's entirely avoidable: two nested at-rules, and a habit of authoring animations as an addition to a readable page rather than as the mechanism that makes the page readable.