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.