How to Use CSS Scroll-Driven Animations (2026)
For fifteen years, scroll-linked animation on the web meant the same thing: attach a listener to the scroll event, read window.scrollY sixty times a second, do math, write inline styles, and pray the main thread stays unblocked long enough for it not to jank. The CSS scroll-driven animations module kills that whole pattern. You now write a normal @keyframes rule, point animation-timeline at scroll() or view(), and the browser handles the rest — on the compositor thread, off the main thread, buttery on mobile. This guide is the short version of what you need to know to ship it in 2026.
- CSS scroll-driven animations let you fade, slide, and scale elements as the user scrolls — in pure CSS, off the main thread, no JavaScript scroll listeners.
- Why Scroll-Driven, Not Scroll-Listened.
- The Two Timeline Types.
- Covers entry, exit, contain, cover.
- Covers your first scroll animation.
Why Scroll-Driven, Not Scroll-Listened
The old pattern — attach a listener to window.scroll, read scroll position, update element styles — has three deep problems that no amount of clever code fixes. First, scroll events fire at whatever rate the browser ships them, which is sixty per second on a good day and many more during a trackpad flick, so every listener is a handwritten exercise in throttling. Second, reading scrollY forces a layout recalculation, and writing inline styles dirties layout again, so you are thrashing the main thread on every frame. Third, modern browsers hand scrolling off to the compositor thread by design, which means your JavaScript is fundamentally running out of phase with the scroll itself — the animation can never be perfectly in sync with the scroll position, because it is driven by a different thread.
Scroll-driven animations solve all three problems at once by not using JavaScript. The browser ties the animation to the scroll timeline directly, on the same thread that drives the scroll, and updates the animation as a side effect of scrolling. No events, no throttling, no thrash. On any property that composites cheaply — transform, opacity, filter, clip-path — this is the performance ceiling. You cannot beat it with JavaScript no matter how much you try.
The Two Timeline Types
The spec introduces two new timeline types, and picking the right one is the first decision for any animation you write.
-webkit-backdrop-filter alongside backdrop-filter for Safari support. Without the prefix, the effect is invisible to roughly 25% of mobile users.scroll() tracks the scroll progress of a scroll container. The animation runs from 0% when the container is scrolled to the top, to 100% when it is scrolled to the bottom. By default the container is the root (the page itself), but you can target a nearest scrollable ancestor with scroll(nearest) or an element’s own scroll with scroll(self). Use this for effects that should track the entire page: a reading progress bar at the top of an article, a sticky nav that shrinks as you scroll down, a full-page parallax background.
view() tracks a specific element’s visibility within its scroll container. The animation progresses as the element enters the viewport, passes through, and exits. Use this for the common case: fade-ins, slide-ins, image zoom-on-scroll, section reveals — anything where the animation is per-element rather than global. Almost every scroll animation on a typical website is a view() animation.
/* scroll() — tied to the page's scroll progress */
.reading-bar {
animation: fillBar linear;
animation-timeline: scroll(root block);
}
/* view() — tied to this element's visibility */
.fade-in-section {
animation: fadeIn linear;
animation-timeline: view();
}entry, exit, contain, cover
For view() timelines, you almost always want to specify animation-range — the window inside the element’s scroll life when the animation actually runs. There are four named ranges, and understanding them is the main conceptual work of scroll-driven animation.
backdrop-filter inside a position: fixed element can cause severe scroll performance issues. Test thoroughly on real iOS devices.entry — the animation runs while the element is entering the viewport. From the moment the first pixel appears at the bottom edge, through until the element is fully visible. Good for slide-ins and fade-ins that should finish by the time the user can see the whole thing.
exit — the animation runs while the element is leaving. From fully-visible until the last pixel disappears at the top edge. Good for fade-outs as content scrolls past.
contain — the animation runs only while the element is fully inside the viewport (both top and bottom edges inside the scrollport). Shorter and more precise than cover. Good for subtle effects that should only trigger when the user can fully see the element.
cover — the animation runs the entire time any part of the element is visible. entry + contain + exit combined. This is the most common default and the one you want for long-scrubbing effects like parallax that should feel continuous through the whole viewing.
You can also mix and match with percentages for precise control: animation-range: entry 0% cover 50% starts the animation when the element first enters and ends it halfway through the cover range. The mental model that helps most: think of the element’s scroll life as a timeline from −100% (not yet visible, bottom edge below screen) to 200% (fully gone past, top edge above screen), where 0% is the moment the first pixel enters and 100% is the moment the last pixel exits. The range keywords are just shorthand for common slices of that timeline.
Your First Scroll Animation
The simplest useful scroll-driven animation is a fade-in-on-scroll. Here it is, complete, with no JavaScript:
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in-section {
animation: fadeInUp 1s linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}Four things worth noting about this code. First, the @keyframes is an entirely ordinary CSS animation — nothing new. Second, the animation-duration of 1s is effectively ignored once animation-timeline is set to a non-default timeline; scroll progress replaces time. Duration still has to be syntactically valid because the animation shorthand expects it, but it does not control anything. Third, both as the fill mode is important: it means the element keeps the final state after the animation completes, so the element does not snap back once fully scrolled into view. Fourth, linear easing is almost always correct for scroll-driven animations — the user’s scroll is already their easing curve, and applying additional easing tends to make motion feel uneven.
Browser Support in 2026
This is where scroll-driven animations stop being the newest shiny thing and start being production-safe, with one persistent asterisk.
Chromium-based browsers — Chrome, Edge, Opera, Brave — have had full support since version 115, which shipped in mid-2023. Safari shipped support in version 18. Firefox, at the time of writing, still has it behind a flag by default. That means a real, non-trivial slice of users — anyone on Firefox without the flag enabled, which is the vast majority of Firefox users — will see no animation at all if you do nothing to handle it.
The good news is that the fallback pattern is one extra line of CSS. If you always wrap your scroll-driven rules in @supports (animation-timeline: view()), browsers that do not support the property skip the rules entirely and the element shows in its natural, undecorated state. As long as that natural state is the final state of the animation — which the both fill mode ensures for supporting browsers — the fallback is visually clean: no animation on Firefox, animation on Chrome and Safari, nothing broken either way.
The @supports Fallback Pattern
This is the pattern worth internalizing for every scroll-driven animation you ship in 2026:
@supports (animation-timeline: view()) {
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in-section {
animation: fadeInUp 1s linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}A subtle detail: you can put the @keyframes inside the @supports block too, or outside it. If outside, browsers that do not support scroll-driven animations will still parse the keyframes but simply never use them (because the animation-name is never applied). Keeping keyframes inside the feature query keeps all of the scroll-animation code in one block, which makes it easier to delete or modify as a unit.
For elements that need to look presentable even without the animation — which is essentially all of them — make sure the un-animated state is the post-animation state, not the pre-animation state. In practice this means: if your animation fades from opacity: 0 to opacity: 1, do not set opacity: 0 as the default outside the @supports block. If you do, Firefox users will see an invisible page. Let the animation be additive: the element is fully visible by default, and the animation overrides that only in supporting browsers.
Respecting Reduced Motion
Every major operating system — macOS, Windows, iOS, Android — has a “reduce motion” accessibility setting, and a non-trivial number of people use it. For some users, heavy parallax or aggressive scroll-linked motion causes genuine vestibular distress — dizziness, nausea, disorientation. Respecting the preference is not a nice-to-have.
The pattern is to wrap scroll-driven animations in @media (prefers-reduced-motion: no-preference), which means “only apply this if the user has NOT asked for reduced motion.” Combined with the feature query:
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in-section {
animation: fadeInUp 1s linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
}Three layers of gracefulness here. Reduced-motion users: no animation, ever. Firefox users without the flag: no animation, but the page still works. Modern Chromium and Safari users who have not set reduced motion: the full animation. No JavaScript, no polyfill, no special-case CSS for each browser.
Performance and What Not to Animate
Scroll-driven animations inherit the performance profile of regular CSS animations, which means the old rule still applies: animate only properties that can run on the compositor thread. Those properties are transform, opacity, filter, clip-path, and in most cases backdrop-filter. Anything else — width, height, top, left, margin, padding, background-color — triggers layout or paint, which runs on the main thread, which defeats the whole point of using scroll-driven animations in the first place.
If you want to animate size, animate transform: scale() instead. If you want to animate position, use transform: translate() rather than top / left. If you want to animate color, sometimes you can fake it with opacity on a colored overlay. This is standard CSS animation advice, but it matters more for scroll-driven because the whole selling point is off-main-thread smoothness.
Five Patterns You Will Actually Use
Most real-world scroll-driven animation distills down to five patterns that show up over and over.
Fade-in-on-scroll. The hello world. opacity: 0 → 1 combined with a small translateY for a hint of upward motion. Use animation-range: entry so the animation completes as the element enters, not as it exits. Ship it on card grids, section reveals, image galleries.
Parallax background. The element moves at a different speed from the scroll, giving a sense of depth. transform: translateY() from a positive value to a negative one, with animation-range: cover so the effect runs across the entire scroll life. Keep the range subtle (30–80px) or it looks cheesy.
Reading progress bar. A fixed-position element at the top of the page that fills as the user scrolls. transform: scaleX(0) → scaleX(1) with transform-origin: left and animation-timeline: scroll(root block). Ten lines of CSS, replaces a hundred lines of JavaScript.
Image zoom-on-scroll. Image starts at scale(1.2) and eases toward scale(1) as it enters the viewport. Feels premium and costs nothing. Pair with overflow: hidden on the parent so the oversized initial state does not leak out.
Sticky shrinking header. The header starts tall and shrinks as the user scrolls down the first screen. scroll() timeline with animation-range: 0 200px restricts the animation to the first 200px of scroll. Transform the height via scale on a child, not by animating the header height itself.
Six Gotchas That Will Catch You
One — animation-duration is ignored for non-default timelines. You still have to write it for the animation shorthand to parse, but it does nothing. Do not spend time tuning durations; they have no effect.
Two — declare animation-timeline AFTER the animation shorthand if you use the shorthand. The shorthand implicitly resets timeline to the default, so if animation-timeline comes first it gets clobbered. Put the shorthand first, then the timeline property, then the range.
Three — use animation-fill-mode: both (or the both keyword in the animation shorthand) or your element will snap back to its pre-animation state when the scroll passes the animation range. This is the single most common bug in the wild.
Four — linear easing is almost always correct for cover-ranged animations. The user’s scroll velocity already provides easing. Applying cubic-bezier on top of that produces odd double-easing that feels wrong.
Five — do not set the pre-animation state as the default outside the @supports block. If the element is invisible by default and the animation fades it in, Firefox users will see nothing. Always make the post-animation state the default, and let the animation override it only in supporting browsers.
Six — scroll-driven animations interact in surprising ways with will-change, contain, and nested scroll containers. If an animation is mysteriously not running, check whether an ancestor has overflow: hidden (which creates a scroll container) or whether the element is inside an iframe (which has its own viewport). The debugging trick: apply the animation to a direct child of body with no ancestors in play, confirm it works, then add back the surrounding structure one layer at a time.
Frequently Asked Questions
What are CSS scroll-driven animations?
Which browsers support them in 2026?
What is the difference between scroll() and view()?
What do entry, exit, contain, and cover ranges mean?
Do scroll-driven animations run on the GPU?
How do I add a reduced-motion fallback?
Pick an effect, preview it live, copy CSS with fallbacks already baked in — no JavaScript, no polyfills.
⚡ Open Scroll Animation Builder