Driving Animations with Scroll-Timeline Polyfills

Part of Scroll-Driven Animation Patterns in Modern View Transitions & Scroll APIs.

Problem Framing

When animation-timeline: scroll() is absent — Chrome < 115, Firefox builds without the layout.css.scroll-driven-animations.enabled flag, or any environment where native scroll-timeline parsing fails — scroll-driven visual feedback silently collapses to either a static initial state or a janky JavaScript loop that misses the 16.6 ms frame budget. The missing piece is a polyfill that maps scroll progress to animated properties without touching the main thread’s layout pipeline.


Scroll-timeline: native vs polyfill execution paths Left side shows native scroll-timeline bypassing main thread directly to compositor. Right side shows the polyfill path: passive scroll listener → rAF → CSS custom property → compositor. Native scroll-timeline Polyfill path Scroll position Compositor thread transform / opacity No main-thread work passive scroll listener requestAnimationFrame --scroll-progress → compositor Main thread: rAF batch only

Root Cause Analysis

Traditional fallbacks bind window.addEventListener('scroll') directly to DOM mutations. Reading getBoundingClientRect() or scrollTop synchronously inside that handler forces the browser to flush pending style recalculations before returning a value — the classic forced layout pattern. Each scroll tick then triggers a cascading layout thrash: read geometry → invalidate style → recalculate → repaint → composite. The compositor never gets a clean frame because the main thread is perpetually mid-layout. On Chrome < 115 the browser also lacks the internal ScrollTimeline object, so animation-timeline: scroll() is silently ignored, leaving elements in their animation-delay or initial keyframe state with no fallback motion at all.

Decision Matrix: Choosing a Polyfill Strategy

Scenario Recommended approach Compositing tier
Entry fade/slide when element enters viewport IntersectionObserver (threshold array) Composite
Continuous parallax tied to scroll distance Passive scroll listener + rAF Composite (rAF batch)
Progress indicator (e.g. reading bar) Passive scroll listener + rAF Composite
Sticky header opacity on scroll-up IntersectionObserver sentinel element Composite
Scroll-snap position tracking scrollend event + rAF Composite

All strategies write their result to a CSS custom property (--scroll-progress) and let CSS transform/opacity consume it — keeping actual pixel mutations off the main thread.

Production Code Pattern

Step 1 — Feature detection and reduced-motion guard

Always gate the polyfill behind both a feature test and the user’s motion preference. When native support exists, the browser’s compositor handles everything natively through Scroll-Driven Animation Patterns.

// Run once at startup. If either condition is true, do nothing.
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const nativeSupport  = CSS.supports('animation-timeline', 'scroll()');

if (!prefersReduced && !nativeSupport) {
  initScrollPolyfill();
}

Rendering Impact: main-thread — feature detection only; no layout reads.

Step 2 — IntersectionObserver for threshold-based entry effects

Use this when you need a discrete progress value at each 1% of intersection, not a continuously updated value.

function initScrollPolyfill() {
  // Build a 0-to-1 threshold array with 101 stops (0%, 1%, … 100%).
  // Mirrors the granularity of native scroll-timeline at 1% precision.
  const thresholds = Array.from({ length: 101 }, (_, i) => i / 100);

  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      // intersectionRatio maps directly to animation-timeline progress.
      // Clamped to [0, 1] to guard floating-point edge cases.
      const progress = Math.min(1, Math.max(0, entry.intersectionRatio));

      // Write to CSS custom property — CSS handles all interpolation from here.
      entry.target.style.setProperty('--scroll-progress', progress.toFixed(3));
    });
  }, {
    rootMargin: '0px',
    threshold: thresholds
  });

  document.querySelectorAll('.scroll-animate').forEach(el => observer.observe(el));
}

Rendering Impact: composite — CSS reads --scroll-progress on the compositor; no layout invalidation after initial observe.

Step 3 — rAF-throttled listener for continuous scroll progress

When an element must track scroll position continuously (reading progress bar, parallax), replace the observer with a passive listener batched into a single rAF callback.

let rafPending = false;

function readScrollProgress() {
  document.querySelectorAll('.scroll-animate').forEach(el => {
    const rect = el.getBoundingClientRect();  // Only read inside rAF — never in the scroll handler
    const vh   = window.innerHeight;
    // 0 when the element's top is at the viewport bottom; 1 when at the viewport top.
    const progress = Math.max(0, Math.min(1, 1 - rect.top / vh));
    el.style.setProperty('--scroll-progress', progress.toFixed(3));
  });
  rafPending = false;
}

window.addEventListener('scroll', () => {
  if (!rafPending) {
    rafPending = true;
    window.requestAnimationFrame(readScrollProgress); // Batch all reads to next paint
  }
}, { passive: true }); // passive: true — browser never waits for this handler before scrolling

Rendering Impact: main-thread (rAF batch only) → composite — getBoundingClientRect() is batched inside the rAF, eliminating forced layout. All paint work is handed off once --scroll-progress is set.

Step 4 — CSS consumption with compositor-safe properties

/*
 * will-change promotes this element to its own compositor layer before
 * any scroll event fires, so transform/opacity updates never trigger repaint.
 * contain: layout style paint prevents scroll tick from invalidating ancestors.
 */
.scroll-animate {
  will-change: transform, opacity;
  contain: layout style paint;
  transform: translateY(calc((1 - var(--scroll-progress, 0)) * 40px));
  opacity:   calc(0.3 + var(--scroll-progress, 0) * 0.7);
}

/*
 * Reduced-motion gate: static visibility, no will-change overhead.
 * !important overrides any specificity from the scroll polyfill.
 */
@media (prefers-reduced-motion: reduce) {
  .scroll-animate {
    transform:    none !important;
    opacity:      1    !important;
    will-change:  auto;
    contain:      none;
  }
}

Rendering Impact: composite — transform and opacity are the only compositor-safe animated properties. Adding top, height, margin, or background-color here forces layout or paint on every frame.

Verification Checklist

Constraints and Trade-offs

  • will-change budget: Each promoted element claims GPU VRAM for its own compositor layer. Promoting more than ~20 elements simultaneously risks VRAM exhaustion on low-end devices, causing the browser to fall back to software rasterization — worse than no promotion at all.
  • IntersectionObserver precision ceiling: Even with 101 thresholds, the observer fires asynchronously at the next task checkpoint, not synchronously with each pixel of scroll. Fast flicks can skip intermediate ratios. The rAF listener is more precise for high-velocity scroll effects.
  • iOS Safari momentum scrolling: Avoid overflow: hidden on <body> or position: fixed hacks inside the polyfill — they disable momentum scrolling on iOS. Keep the native scroll container intact.
  • Browser support floor: IntersectionObserver is available from Chrome 51, Firefox 55, and Safari 12.1. The rAF pattern works universally. Neither approach requires a polyfill for the observer itself in any currently supported browser.
  • Dynamic content: Elements injected after initScrollPolyfill() runs are not observed. Wire a MutationObserver or a framework lifecycle hook to call observer.observe(newEl) on each injected .scroll-animate node.
  • contain: layout style paint: This creates a new stacking context and a new block formatting context. Elements that need to visually overflow their box (e.g. drop shadows, absolutely positioned children) may be clipped unexpectedly — audit visually before shipping.