Scroll-Driven Animations: animation-timeline: scroll() Compositor Sync
- Last updated
- Prerequisites:
- CSS animations and keyframes
- Understanding of scroll events and the compositor
- css
- scroll-driven-animations
- animation-timeline
- compositor
- scroll
- keyframes
Read at your depth
The practical view
animation-timeline: scroll() ties an animation's progress to scroll position instead of time. .progress { animation: grow linear; animation-timeline: scroll(); } makes the animation play as you scroll (0% at scroll start, 100% at scroll end). Use scroll(root) for the page or scroll(#container) for an element; the range option (e.g. scroll(nearest) or animation-range: cover 0% cover 100%) sets which scroll portion maps to the animation. This enables scroll-linked effects (progress bars, parallax, reveal-on-scroll) in pure CSS with no scroll listeners.
The same idea in other frameworks
React components can drop scroll-position JS state in favor of CSS animation-timeline for progress/parallax effects.Angular animations (triggers) remain time-based; scroll-driven CSS is a complementary pure-CSS option for scroll effects.Vue transitions are time-based; scroll-driven animations live in scoped CSS and reduce scroll-listener code in composables.Svelte components benefit directly — no $effect scroll listeners needed when the compositor can drive the animation.Legacy vs modern
Scroll listener + rAF progress vs compositor scroll() timeline
JS scroll handling samples on the main thread and can jank; animation-timeline: scroll() drives progress on the compositor with zero main-thread work.
addEventListener('scroll', () => {
bar.style.transform = `scaleX(${scrollY / max})`; // ❌ OLD: main-thread per scroll event
});.progress-bar { animation: grow linear; animation-timeline: scroll(); } /* ✅ NEW */Interview gotchas
Context
This separates candidates who understand the compositor from those who only know 'scroll effects' — the main-thread vs compositor-thread distinction is the substance.
The mechanical answer
A scroll listener runs on the main thread: the scroll event fires, JS runs (possibly scheduling a rAF), styles change, and the engine recalculates style and paint — all main-thread work that competes with input handling and rendering. Scroll-driven animations define a ScrollTimeline whose currentTime is the scroll offset; for compositor-eligible properties (transform, opacity), the compositor thread updates the animation progress directly from its own scroll position, bypassing the main thread entirely. The result is frame-aligned progress updates that cannot be starved by main-thread work. Even for non-compositable properties, the engine still benefits from a single consistent timeline source rather than event-driven JS sampling.
Trap
The naive answer is 'it's just a CSS shortcut for the same scroll listener'. It is a different execution model (compositor-driven vs main-thread event-driven). Another trap: assuming any property animates smoothly — only transform/opacity run fully on the compositor; animating width/height/top still engages main-thread layout each frame. Candidates who mention compositor eligibility and the ScrollTimeline source demonstrate real understanding.
Context
This tests precise knowledge of the two timeline types and the range mapping — the details that determine whether an effect tracks the page scroll or an element's visibility.
The mechanical answer
scroll() creates a ScrollTimeline whose progress maps the scroll container's scroll offset: 0% is the scroll origin, 100% is the max scroll. view() creates a ViewTimeline keyed to a specific element: its progress is derived from the element's position relative to the scroller — 0% when the element first enters, 100% when it exits. animation-range then maps a sub-portion of that timeline to the keyframes' 0%-100%: for a view() timeline you can use range names like entry, cover, exit (e.g. animation-range: entry 20% exit 80%) to make an element start animating after it is partially visible and finish before it fully leaves. For scroll(), ranges are length/percentage offsets along the scrollable distance.
Trap
The naive answer is 'view() is for elements, scroll() is for the page — same thing'. The progress math differs fundamentally: scroll() is absolute to the scroller, view() is relative to the element's traversal through the scroller. Another trap: forgetting animation-range — without it the default maps the whole timeline, so a view() animation finishes as soon as the element is fully visible rather than as it leaves. Candidates who can name entry/cover/exit and explain the mapping show real depth.