The Rendering Pipeline for Scroll Animations
CSS scroll-driven animations are architecturally different from JavaScript scroll listeners in more than syntax. When correctly configured, they bypass style recalculation, layout, and paint entirely during scroll — the compositor thread interpolates directly between pre-rasterized GPU textures at the display’s native refresh rate. This page explains how that pipeline works, which properties keep you on the fast path, and how to confirm compositor isolation with DevTools. For the broader API context, see Core Animation Fundamentals & Browser Mechanics.
Pages in this section
- When to Use CSS Animations Over JavaScript Libraries — decision criteria, GSAP/Framer Motion comparisons, and hybrid patterns
- Debugging Scroll Animation Timing Functions — DevTools easing curves, stall diagnosis, and
linear()step debugging - CSS Scroll-Driven Animations vs GSAP ScrollTrigger — threading model, feature set, and bundle-cost comparison between the native API and GSAP
Syntax reference
The two declarations that wire a CSS animation to a scroll timeline:
/* Attach to the document's block-axis scroll progress */
animation-timeline: scroll(root block);
/* Restrict animation to a specific scroll window */
animation-range: entry 0% cover 50%;
| Property | Value type | Default | Notes |
|---|---|---|---|
animation-timeline |
scroll() | view() | <custom-ident> |
auto |
auto means the CSS animation-duration clock; scroll() replaces it with a scroll-position clock |
animation-range |
<timeline-range-name> <percentage> pairs |
normal (0 % – 100 %) |
Narrows the active window; compositor skips interpolation outside the range |
animation-fill-mode |
both | forwards | backwards |
none |
both holds the start/end keyframe state outside the range |
will-change |
transform | opacity | filter |
auto |
Pre-allocates a compositor layer; overuse wastes GPU memory |
contain |
layout paint style |
none |
Prevents style invalidations from escaping the subtree |
content-visibility |
auto | hidden |
visible |
Defers layout and paint for off-screen sections |
For the full scroll-timeline named-timeline API, see Understanding the CSS Scroll-Timeline API.
Minimal working example
Self-contained — no build step, no JavaScript, no dependencies:
<style>
@keyframes fade-and-slide {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: no-preference) {
.reveal {
animation: fade-and-slide linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
</style>
<section class="reveal">
<h2>Revealed on scroll</h2>
<p>This element fades in as it enters the viewport.</p>
</section>
view() creates an anonymous ViewTimeline scoped to the element’s intersection with its scroll container. The animation-range: entry 0% entry 60% means the animation runs while the element transitions from fully below the viewport to 60 % of its entry height visible.
animation-range and timeline scoping
animation-range is the primary tool for limiting when the compositor performs keyframe interpolation. Without it, the browser calculates the animated value for every scroll position across the full scroll height.
/* Named keywords: entry, exit, cover, contain */
.card {
animation-timeline: view();
animation-range: entry 0% /* starts when element enters viewport */
cover 30%; /* ends when element covers 30% of the scroller */
}
/* Named scroll timelines let siblings share a single timeline source */
@scroll-timeline progress-bar {
source: selector(#page);
axis: block;
}
.progress-indicator {
animation-timeline: progress-bar;
animation-range: 0% 100%;
}
Named scroll timelines (via scroll-timeline-name / scroll-timeline-axis on an ancestor, then animation-timeline: --my-name on the target) allow multiple elements to share one timeline without each re-deriving scroll position. This is particularly useful for synchronized multi-element sequences.
Compositor-safe properties
The compositor thread runs independently of the main thread. It composites pre-rasterized layer bitmaps using the GPU. CSS scroll-driven animations that animate only transform, opacity, or filter never need to return to the main thread during scroll — the compositor reads the scroll position and interpolates the animated value without triggering style recalculation.
Properties that affect geometry (width, height, top, left, margin, padding) or appearance (background-color, box-shadow, border) force the browser back through style → layout → paint before the compositor can proceed. At 60 fps, each recalc cycle must complete in under 16.6 ms; at 120 Hz, under 8.3 ms.
will-change guidance
/* Signal the browser to promote this element before animation starts */
.scroll-animated-card {
will-change: transform, opacity;
/* Containment prevents style invalidation from escaping the subtree */
contain: layout paint style;
/* Defer layout/paint for off-screen sections */
content-visibility: auto;
}
/* Reset when animation is finished — each promoted layer costs GPU memory */
.scroll-animated-card.animation-complete {
will-change: auto;
}
Do not apply will-change site-wide. Every promoted layer consumes a dedicated GPU texture. On mobile devices with shared CPU/GPU memory, over-promotion causes the browser to evict textures mid-scroll, producing visible compositing tears.
Common implementation patterns
1. Parallax background
@keyframes parallax-shift {
from { transform: translateY(0); }
to { transform: translateY(-25%); }
}
@media (prefers-reduced-motion: no-preference) {
.hero-background {
will-change: transform;
contain: layout paint style;
animation: parallax-shift linear both;
animation-timeline: scroll(root block);
animation-range: cover 0% cover 100%;
}
}
cover 0% cover 100% means the animation runs for the full duration that the element covers any part of the scroller’s viewport.
2. Reading progress bar
@keyframes widen {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.reading-progress {
position: fixed;
top: 0; left: 0;
height: 3px;
width: 100%;
transform-origin: left center;
background: currentColor;
animation: widen linear both;
animation-timeline: scroll(root block);
}
See Building Scroll Progress Indicators for the full pattern including accessible labelling.
3. Reveal on scroll
@keyframes reveal {
from { opacity: 0; translate: 0 2rem; }
to { opacity: 1; translate: 0 0; }
}
@media (prefers-reduced-motion: no-preference) {
.reveal-card {
animation: reveal ease-out both;
animation-timeline: view();
animation-range: entry 0% entry 55%;
}
}
translate is a standalone transform property (CSS Transforms Level 2) and is compositor-safe. Using it instead of transform: translateY() avoids clobbering other transforms on the same element.
4. Sticky header state change
@keyframes header-shrink {
from { padding-block: 1.5rem; box-shadow: none; }
to { padding-block: 0.5rem; box-shadow: 0 2px 8px rgb(0 0 0 / 0.2); }
}
@media (prefers-reduced-motion: no-preference) {
.site-header {
animation: header-shrink linear both;
animation-timeline: scroll(root block);
animation-range: 0px 80px; /* first 80px of scroll */
}
}
Note that padding-block and box-shadow are not compositor-safe — they trigger paint. This pattern is fine for a one-time state change at the scroll start (browser paints once), but would be expensive if applied to a continuous mid-page scroll range. See Animating Sticky Headers on Scroll Direction Change for a direction-aware version using JavaScript.
Browser support and @supports guard
| Feature | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
scroll() timeline |
115 | 115 | 110 (flag) / 128 | 18.2 (TP) |
view() timeline |
115 | 115 | 128 | 18.2 (TP) |
Named scroll-timeline |
115 | 115 | 128 | 18.2 (TP) |
animation-range keywords |
115 | 115 | 128 | 18.2 (TP) |
content-visibility: auto |
85 | 85 | 125 | 18 |
Always gate scroll-driven declarations behind @supports:
@supports (animation-timeline: scroll()) {
.hero-background {
animation: parallax-shift linear both;
animation-timeline: scroll(root block);
animation-range: cover 0% cover 100%;
}
}
For Safari fallbacks using the JavaScript polyfill, see How to Polyfill scroll-timeline for Safari. For a full breakdown of the progressive enhancement strategy beyond @supports, see Browser Support & Progressive Enhancement.
IntersectionObserver fallback
if (!CSS.supports('animation-timeline', 'scroll()')) {
const thresholds = Array.from({ length: 21 }, (_, i) => i / 20);
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
requestAnimationFrame(() => {
const ratio = entry.intersectionRatio;
entry.target.style.opacity = String(ratio);
entry.target.style.transform = `translateY(${(1 - ratio) * 32}px)`;
});
}
});
}, { threshold: thresholds });
document.querySelectorAll('.reveal-card').forEach(el => observer.observe(el));
}
For a detailed comparison of the two approaches at the decision level, see CSS Scroll-Driven Animations vs IntersectionObserver.
Gotchas and failure modes
-
Compositor promotion doesn’t happen automatically. Declaring
animation-timeline: scroll()does not guarantee compositor promotion. The browser will promote the element only when the animated properties are in the safe set (transform,opacity,filter) and there is no stacking context conflict. If the flame chart still showsLayoutorPaintevents during scroll, check for inherited transforms orz-indexchanges on ancestor elements. -
animation-fill-mode: bothis not always safe.fill-mode: bothholds the keyframe state before and after the active range. If yourfromkeyframe setsopacity: 0, elements outside the scroll range are invisible — including when JavaScript navigation bypasses scroll (e.g. anchor links that jump the page). Always verify element visibility at page load and at the bottom of the page. -
will-changeon too many elements forces compositing of entire subtrees. Applyingwill-change: transformto a container promotes all descendants as well, not just the target element. This can push GPU memory over the device budget, causing the browser to silently de-promote layers and fall back to main-thread painting mid-scroll. Keepwill-changeon leaf-level animated elements. -
contain: layout style paintbreaks sticky positioning.contain: layoutestablishes a new formatting context and removes the element from the flow of its nearest scroll ancestor. Positioned descendants withposition: stickywill use the contained element as their scroll container instead of the page. If a sticky child stops working after you add containment, switch tocontain: style paint(droplayout). -
animation-rangepercentages are relative to the timeline, not the viewport.animation-range: entry 0% entry 100%withview()covers the full entry phase of the subject’s own intersection with the scroller. If the scroller is adivrather than the root viewport, the percentage is relative to that inner container’s height — which may produce unexpected results when the container hasoverflow: hiddenwith no explicit height. -
Scroll-driven animations do not fire events. There is no
animationstartoranimationendevent on scroll-driven animations when the element enters or exits the range. If you need lifecycle callbacks (e.g. to toggle a class for non-animated state changes), use anIntersectionObserveralongside the CSS animation, or use the WAAPIAnimation.ready/Animation.finishedpromises from JavaScript.
DevTools profiling workflow
- Open DevTools → Performance panel. In the Rendering side-panel, enable Paint flashing and Layer borders.
- Record a 3-second slow scroll session (throttle CPU to 4× in the Performance panel’s settings for a realistic mobile baseline).
- In the flame chart, filter for
Layout,Paint, andComposite Layers.LayoutorPaintevents during scroll indicate the animation is not compositor-isolated. - Identify long tasks (purple blocks > 16 ms). Click into them and trace the call tree to the specific DOM node and property.
- Fix: add
will-change: transformto the animated element, or replace the non-safe property with atransformequivalent. - Re-profile without CPU throttling to confirm no
LayoutorPaintevents appear during scroll.
For timing-function and easing-specific debugging steps, see Debugging Scroll Animation Timing Functions.
Performance checklist
- Animate only
transform,opacity, orfilterin scroll keyframes - Never animate
width,height,top,left,margin,padding, orbackground-coloron a continuous scroll range - Use
animation-rangeto limit the active interpolation window - Apply
content-visibility: autoto off-screen scroll sections with a fixed block size hint (contain-intrinsic-size) - Gate all scroll-driven declarations inside
@supports (animation-timeline: scroll()) - Wrap fallback JS inside
requestAnimationFrameto prevent mid-frame style reads - Add
prefers-reduced-motion: no-preferenceguards on every scroll animation - Reset
will-change: autoon elements after their animation completes - Keep
will-changeon leaf elements, not containers - Test on CPU-throttled mobile profiles (4× slowdown) in DevTools before shipping
Accessibility
Always wrap scroll animations in a prefers-reduced-motion guard. The safest pattern is the no-preference guard — animations are off by default and only enabled when the user has not requested reduced motion:
/* OFF by default — only animate when the user allows it */
@media (prefers-reduced-motion: no-preference) {
.scroll-reveal {
animation: reveal ease-out both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
}
If you prefer to write animations unconditionally and override in the reduce block, you must reset both animation and animation-timeline — resetting only animation leaves an orphaned animation-timeline declaration that may still run at near-zero duration, producing a flash:
/* Unconditional — but you must reset both properties */
.scroll-reveal {
animation: reveal ease-out both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@media (prefers-reduced-motion: reduce) {
.scroll-reveal {
animation: none;
animation-timeline: auto; /* required — resets the timeline source */
}
}
For comprehensive guidance on prefers-reduced-motion negotiation across scroll and view-transition animations, see Implementing prefers-reduced-motion.
Frequently Asked Questions
Does a scroll-driven animation actually run on the compositor thread?
The interpolation does, provided the animated property is one the compositor can handle on its own — transform, opacity and filter. The progress value comes from the scroll position, which the compositor already tracks in order to scroll, so the whole loop can complete without the main thread participating in any frame.
The moment the animation targets a property outside that set, the arrangement changes: progress is still cheap, but applying it requires style, layout or paint on the main thread, and the animation inherits the main thread’s availability. That is the entire performance story in one sentence — the timeline is cheap everywhere, and the property decides the rest.
Why does my animation stutter even though I only animate transform?
Three causes account for nearly all of it. The element may not be on its own compositing layer, in which case each frame re-rasterises it into a parent layer; the Layers panel confirms this in seconds. The page may have promoted so many layers that the compositor is evicting textures, which reads as stutter or brief blank areas. Or something else on the page — a layout-affecting animation, a long task — may be pushing whole frames past their deadline, in which case your animation is a victim rather than a cause.
Distinguishing them is quick: if the page also stops responding to clicks, it is the third; if it stays responsive, it is one of the first two.
Is will-change needed on a scroll-driven animation?
Usually not. An element with a running transform animation is promoted by the browser anyway. The hint is useful for eliminating the one-frame delay before promotion happens on an animation that starts abruptly, and for elements that are about to animate but are not yet.
Applied permanently to everything that might animate, it is actively harmful: each hint holds a texture whether or not the element ever moves, which is how pages arrive at the layer-eviction problem in the previous answer.
Does the pipeline change when the animation is inside a scroll container rather than the document?
Not in principle. A nested scroll container has its own scroll offset that the compositor tracks the same way, and a timeline attached to it behaves identically. What does change is which box the anonymous form resolves to — scroll(nearest) walks up to the closest scrollable ancestor, which may not be the container you had in mind if an intermediate element also scrolls.
Related
- Understanding the CSS Scroll-Timeline API — named timelines,
@scroll-timeline, axis and source options - How @view-transition Works Under the Hood — snapshot capture, pseudo-element layers, and GPU bitmap allocation
- Parallax Effects with Pure CSS — full parallax patterns using
scroll()timelines - Fallback Strategies for Legacy Browsers — comprehensive
@supportsrecipes and polyfill integration