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:

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


Up: Core Animation Fundamentals & Browser Mechanics