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
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.
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