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


Syntax reference

The two declarations that wire a CSS animation to a scroll timeline:

What each property costs per frame Four property groups mapped onto the pipeline. Transform and opacity re-run compositing only. Filter re-runs paint and compositing. Colour and shadow re-run paint. Width, height, top and left re-run layout, paint and compositing — the full sequence, on the main thread, on every frame. layout paint composite thread transform, opacity yes compositor filter, backdrop-filter yes yes mixed color, background, box-shadow yes yes main width, height, top, left, margin yes yes yes main Scroll-driven animations do not change these costs — they change who pays them and when. A layout-triggering property under a scroll timeline still forces layout, now once per scroll frame.
/* 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

Browser rendering pipeline for scroll animations Diagram showing two rows: the main thread (Style, Layout, Paint) and the compositor thread (Composite). Compositor-safe properties skip the main thread entirely. Unsafe properties trigger a full recalc-layout-paint cycle. Main Thread Style recalculate Layout reflow Paint rasterize JS listener per frame Compositor Composite Layers GPU texture interpolation CSS scroll-driven stays here ✓ ↑ CSS scroll-driven skips main-thread phases above Compositor-safe (no recalc) vs Forces main-thread recalc transform GPU matrix — no layout impact opacity alpha blend — no repaint filter GPU shader — compositor only width/height triggers layout top/left/margin triggers layout background-color triggers paint box-shadow triggers paint The life of a promoted layer A layer is promoted when will-change, a 3D transform or an animating compositor-safe property asks for it. While promoted it holds a texture sized by its painted area, which is width times height times four bytes. The promotion is discarded when the animation ends or the hint is removed. Promotion trigger will-change: transform a running transform animation a 3D transform or filter Own texture on the GPU w × h × 4 bytes a full-screen layer at 2x DPR is roughly 33 MB Discarded when the animation finishes or will-change is removed re-promotion costs a repaint The trade you are making Promoted: the compositor moves an existing texture, so the frame costs almost nothing. Not promoted: the element is re-rasterised into its parent layer every time it moves. Over-promoted: dozens of textures exhaust GPU memory and the compositor starts evicting them, which shows up as blank flashes exactly where the animation should be smoothest.

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

  1. 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 shows Layout or Paint events during scroll, check for inherited transforms or z-index changes on ancestor elements.

  2. animation-fill-mode: both is not always safe. fill-mode: both holds the keyframe state before and after the active range. If your from keyframe sets opacity: 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.

  3. will-change on too many elements forces compositing of entire subtrees. Applying will-change: transform to 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. Keep will-change on leaf-level animated elements.

  4. contain: layout style paint breaks sticky positioning. contain: layout establishes a new formatting context and removes the element from the flow of its nearest scroll ancestor. Positioned descendants with position: sticky will use the contained element as their scroll container instead of the page. If a sticky child stops working after you add containment, switch to contain: style paint (drop layout).

  5. animation-range percentages are relative to the timeline, not the viewport. animation-range: entry 0% entry 100% with view() covers the full entry phase of the subject’s own intersection with the scroller. If the scroller is a div rather than the root viewport, the percentage is relative to that inner container’s height — which may produce unexpected results when the container has overflow: hidden with no explicit height.

  6. Scroll-driven animations do not fire events. There is no animationstart or animationend event 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 an IntersectionObserver alongside the CSS animation, or use the WAAPI Animation.ready / Animation.finished promises from JavaScript.


DevTools profiling workflow

  1. Open DevTools → Performance panel. In the Rendering side-panel, enable Paint flashing and Layer borders.
  2. Record a 3-second slow scroll session (throttle CPU to 4× in the Performance panel’s settings for a realistic mobile baseline).
  3. In the flame chart, filter for Layout, Paint, and Composite Layers. Layout or Paint events during scroll indicate the animation is not compositor-isolated.
  4. Identify long tasks (purple blocks > 16 ms). Click into them and trace the call tree to the specific DOM node and property.
  5. Fix: add will-change: transform to the animated element, or replace the non-safe property with a transform equivalent.
  6. Re-profile without CPU throttling to confirm no Layout or Paint events appear during scroll.

For timing-function and easing-specific debugging steps, see Debugging Scroll Animation Timing Functions.


Performance checklist

  • Animate only transform, opacity, or filter in scroll keyframes
  • Never animate width, height, top, left, margin, padding, or background-color on a continuous scroll range
  • Use animation-range to limit the active interpolation window
  • Apply content-visibility: auto to 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 requestAnimationFrame to prevent mid-frame style reads
  • Add prefers-reduced-motion: no-preference guards on every scroll animation
  • Reset will-change: auto on elements after their animation completes
  • Keep will-change on 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.


Up: Core Animation Fundamentals & Browser Mechanics