Using CSS scroll driven animations with scroll() and view()
How CSS scroll driven animations work: scroll() and view() timelines, a progress bar, a reveal effect, named timelines, and a safe fallback pattern.
CSS scroll-driven animations let scroll position drive an animation instead of the clock. You write a normal @keyframes rule, then point animation-timeline at a scroll timeline, made with scroll(), or a view timeline, made with view(). No JavaScript scroll listener is needed.
A scroll timeline follows how far a scroll container has scrolled. A view timeline follows how far an element has moved through its scroll container. Use the first for progress bars and the second for reveal effects. The rest of this post covers both, plus named timelines, support checks, and the mistakes I'd watch for.
How CSS scroll driven animations work#
The MDN module overview describes it as functionality that builds on the CSS animations module and the Web Animations API. Instead of the default time-based document timeline, the animation advances along a scroll-based timeline. You can animate an element by scrolling the element itself, its scroll container, or its root element.
You pick the scroller in one of two ways:
- Anonymous timelines. Use the
scroll()orview()function directly as the value ofanimation-timeline. - Named timelines. Declare a name with
scroll-timelineorview-timelineon one element, then reference that name fromanimation-timelineon another.
Everything else is the CSS you already know. Keyframes describe the start and end states, and the timeline decides where in those keyframes you are at any scroll position. Scroll to the start and you see 0%. Scroll to the end and you see 100%. Scroll back and the animation runs in reverse, because it's tied to position, not time.
A reading progress bar with scroll()#
Here's the smallest useful example. A fixed bar at the top of the page grows as the page scrolls.
.progress {
position: fixed;
inset: 0 0 auto 0;
height: 4px;
background: currentColor;
transform-origin: left;
animation: grow linear;
animation-timeline: scroll(nearest block);
}
@keyframes grow {
0% {
transform: scaleX(0);
}
100% {
transform: scaleX(1);
}
}
<div class="progress"></div>
Two things to notice. The animation uses linear timing, the same as MDN's example, because easing on a scroll-driven animation makes the visual progress drift away from the actual scroll position. And scroll(nearest block) takes an axis argument. MDN's own example uses scroll(nearest inline) to react to horizontal scrolling, so block is the vertical counterpart for normal horizontal-writing-mode pages.
I'd animate transform rather than width here. It's the same visual result, and it keeps the effect cheap to render.
What the timeline is actually computing#
It helps to know the math. Scroll progress is how far you've scrolled divided by the total scrollable distance. The browser maps that fraction onto your keyframes. This script reproduces that mapping for the progress bar above so you can see the numbers.
const scrollHeight = 4000;
const clientHeight = 800;
const maxScroll = scrollHeight - clientHeight;
function progress(scrollTop) {
return Math.min(1, Math.max(0, scrollTop / maxScroll));
}
// keyframes: 0% -> scaleX(0), 100% -> scaleX(1)
function scaleX(p) {
return 0 + (1 - 0) * p;
}
for (const top of [0, 800, 1600, 3200]) {
const p = progress(top);
console.log(`scrollTop=${top} progress=${p.toFixed(3)} scaleX=${scaleX(p).toFixed(3)}`);
}
The total scrollable distance is content height minus the visible height, which is why a short page makes the bar jump quickly and a long page makes it crawl.
Reveal effects with view()#
view() creates a timeline based on an element's visibility inside its scroll container. The element starts the timeline as it enters and finishes it as it leaves, so a fade-in-and-rise effect needs very little code.
.card {
animation: reveal linear;
animation-timeline: view();
}
@keyframes reveal {
0% {
opacity: 0;
translate: 0 2rem;
}
100% {
opacity: 1;
translate: 0 0;
}
}
Because the timeline spans the whole trip across the viewport, this card reaches full opacity only as it finishes crossing. Often that's later than you want. The module gives you animation-range, plus animation-range-start and animation-range-end, to attach the animation to only part of the timeline. The <timeline-range-name> data type defines the named ranges you can use there, and MDN has a guide on them. I'd read that guide before picking values, since the right range depends on the effect.
There's also view-timeline-inset, which insets the area used to compute visibility. It's useful when a sticky header covers the top of the scrollport and you don't want elements counted as visible while they're hidden behind it.
Named timelines and timeline-scope#
Sometimes the element you want to animate isn't a descendant of the scroller. Named timelines solve that. This is the pattern from MDN's example:
main {
scroll-timeline: --main-timeline;
}
div::after {
animation: shape-animation linear;
animation-timeline: --main-timeline;
}
The scroll-timeline shorthand sets scroll-timeline-name and scroll-timeline-axis. The view equivalents are view-timeline, view-timeline-name, and view-timeline-axis. Names start with two dashes, like custom properties.
The module also lists timeline-scope, which is the property to look at when the animated element sits outside the subtree of the element that declares the timeline. I haven't verified exact scoping behavior against the pack beyond that listing, so check the MDN reference page for it before relying on it for sibling elements.
Browser support, Firefox, and fallbacks#
The source I'm working from, the MDN module page, doesn't include a support table, so I won't quote version numbers. Check Can I use for "CSS scroll-driven animations" before you ship, and check it again when you test, because this feature has been moving.
Firefox is the question most people search for, including the layout.css.scroll-driven-animations.enabled preference. The MDN page I used doesn't mention that preference, so I can't confirm its behavior or whether it's still needed in current Firefox. Look at the current Firefox column on Can I use. As for polyfills, the pack doesn't cover any, so I'm leaving them out. If you need identical behavior everywhere, test whichever library you pick in your real target browsers.
What I'd do regardless is treat the effect as an enhancement. MDN's example detects support with this query:
@supports not (scroll-timeline: --main-timeline) {
body::before {
content: "Your browser doesn't support scroll-driven animations.";
display: block;
text-align: center;
}
}
That's a demo message. For production, flip it around so the animated styles only apply where supported, and everything else gets a perfectly good static page:
.card {
opacity: 1;
}
@supports (scroll-timeline: --main-timeline) {
.card {
animation: reveal linear;
animation-timeline: view();
}
}
The key point is that unsupported browsers see content at its final state. Don't start elements at opacity: 0 outside the @supports block, or users in older browsers get invisible content.
Property and function reference#
| Item | What it does |
|---|---|
animation-timeline |
Assigns the timeline that drives the animation |
scroll() |
Anonymous scroll progress timeline, takes a scroller and an axis such as nearest inline |
view() |
Anonymous view progress timeline for the element's own visibility |
scroll-timeline |
Shorthand for scroll-timeline-name and scroll-timeline-axis |
view-timeline |
Shorthand for view-timeline-name and view-timeline-axis |
view-timeline-inset |
Insets the area used to compute view progress |
timeline-scope |
Controls the scope a named timeline is visible in |
animation-range |
Shorthand for animation-range-start and animation-range-end |
On the JavaScript side, the module lists the ScrollTimeline and ViewTimeline interfaces, which work with the Web Animations API and Element.animate(). The spec itself is the Scroll-driven Animations specification at the W3C.
Common mistakes#
- Using easing. Stick with
linearunless you have a reason. Ease-in or ease-out makes the animation disagree with where the scroll thumb is. - Hiding content outside
@supports. Initial states likeopacity: 0belong inside the support block, as shown above. - Expecting a time-based duration to matter. The scroll position drives progress, so think in terms of start and end ranges instead of seconds.
- Animating layout properties. Width, height, and
topcan cause layout work on every scroll tick. Prefertransform,translate,scale, oropacitywhere you can. - Picking the wrong scroller.
scroll(nearest inline)follows the nearest ancestor scroller in the inline direction. If nothing in the chain scrolls horizontally, nothing moves. Check the axis first when a timeline seems dead. - Forgetting that views end. A
view()animation completes as the element leaves the scrollport, so a reveal may look unfinished while the card is still on screen. That's the cue to reach foranimation-range. - Ignoring motion preferences. Parallax and large movement can bother some users. Wrap heavier effects in a
prefers-reduced-motioncheck and keep the static layout as the default.
Quick checklist#
- Write the
@keyframesfirst, with0%and100%states. - Add
animation: name linear;to the element. - Set
animation-timelinetoscroll(...),view(), or a--namedtimeline. - For named timelines, declare
scroll-timelineorview-timelineon the scroller or subject, and considertimeline-scopewhen the target is outside its subtree. - Trim the active region with
animation-rangeif the effect finishes too late. - Put the whole thing inside
@supports (scroll-timeline: --main-timeline). - Prefer
transformandopacity, and respect reduced motion. - Check current browser support on Can I use, Firefox included, before you ship.
Quick answers
What are CSS scroll-driven animations?
They're CSS animations that advance with scroll position instead of time. You set animation-timeline to scroll(), view(), or a named timeline, and the keyframes play forward and backward as the user scrolls.
What's the difference between scroll() and view()?
scroll() tracks how far a scroll container has scrolled, which suits progress bars. view() tracks an element's visibility within its scroll container, which suits reveal effects.
Does Firefox support CSS scroll-driven animations, and what is layout.css.scroll-driven-animations.enabled?
The MDN module page doesn't state Firefox support or mention that preference, so I can't confirm either from it. Check the current Firefox column on Can I use, and test in the Firefox version you target.
How do I provide a fallback when scroll-driven animations aren't supported?
Wrap the animation rules in an @supports (scroll-timeline: --main-timeline) block and leave the default styles as the fully visible, static layout. Browsers without support then just show the final content.
Next note