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.
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-progresson 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-progressis 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 —
transformandopacityare the only compositor-safe animated properties. Addingtop,height,margin, orbackground-colorhere forces layout or paint on every frame.
Verification Checklist
Constraints and Trade-offs
will-changebudget: 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: hiddenon<body>orposition: fixedhacks inside the polyfill — they disable momentum scrolling on iOS. Keep the native scroll container intact. - Browser support floor:
IntersectionObserveris 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 aMutationObserveror a framework lifecycle hook to callobserver.observe(newEl)on each injected.scroll-animatenode. 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.
Related
- Scroll-Driven Animation Patterns — the parent pattern set this polyfill fits into, covering native
scroll()andview()timelines - View Transitions API Implementation — coordinate scroll state with page transitions to prevent layer promotion conflicts
- Modern View Transitions & Scroll APIs — pillar overview covering all native motion primitives in this browser generation