CSS Scroll-Driven Animations Table
| Piece | What it does | Field note |
|---|---|---|
animation-timeline: scroll() | Scrub by scroll position | Progress 0-100% of the SCROLLER - scroll range becomes the timeline |
scroll(root block) | The classic reading bar | Top progress bar = one declaration, zero JS, zero scroll listeners |
animation-timeline: view() | Element enters to leaves | Per-element reveal - position WITHIN the viewport is the timeline |
animation-range | Trim the active window | entry 0% cover 50% - precise in/out control of the view range |
scroll-snap pairing | Snap + scrub together | Scrub to the section, snap locks it - carousels without JS |
timeline-scope | Cross-tree naming | Name a timeline on an ancestor - children of different trees can share |
@supports (animation-timeline: scroll()) | The gate | Progressive enhancement - browsers without it just see static styles |
No JS in the hot path | The whole point | Scrubbing runs on the compositor - smooth where scroll listeners jank |
Scroll-driven animations invert the model: instead of scroll events driving JavaScript that sets styles, the SCROLL POSITION is the animation timeline - animation-timeline: scroll() scrubs by scroller progress, view() by the element's journey through the viewport.
Bottom line: the classic reading progress bar is ONE declaration (animation-timeline: scroll(root block) over a scaleX keyframe), animation-range trims the active window precisely, and the scrubbing runs on the compositor - smooth where scroll listeners jank. No JS in the hot path is the whole point.
The honest part: these are enhancements with a clean gate - @supports (animation-timeline: scroll()) around the animation declarations, and browsers without support simply see the static styles. Design the base state to be complete (content readable, actions clickable), and the scroll choreography becomes pure delight-layer.
How to use
- Reading progress, one declaration: .bar { transform-origin: 0 50%; animation: grow linear; animation-timeline: scroll(root block); } - scaleX 0 to 1 across the document.
- Reveal on entry: .card { animation: rise linear both; animation-timeline: view(); animation-range: entry 0% entry 60%; } - the card animates during its first 60% of entry.
- Gate the enhancement: @supports (animation-timeline: scroll()) { ...the timeline declarations... } - unsupported browsers keep a complete static page.
Frequently asked questions
What is the difference between scroll() and view() timelines?
Different reference points. scroll() tracks a SCROLLER's progress: the timeline runs from the scroller's start to its end - the classic document-wide reading bar, or a section's local scrollbar. view() tracks an ELEMENT's journey through the viewport: the timeline starts when the element enters and completes when it leaves - per-element reveals and parallax without computing offsets. Choosing: 'how far through the page am I' is scroll(); 'where is this element in its flythrough' is view(). Both are pure CSS declarations attached to the same keyframes you already know.
How does animation-range give precise control over view() animations?
It trims the active window of the element's journey. view() spans entry, cover (fully in view), and exit - and animation-range names the segment: animation-range: entry 0% entry 60% means the animation plays during the first 60% of the entry phase only, then holds its end state (with fill both). Without ranges, the animation stretches across the whole journey, which dilutes fast reveals. The naming takes getting used to (phase percentage pairs), but the mental model is simple: you are telling the timeline 'start here in the journey, finish there' - the scrub is still the scroll, just windowed.
Why is compositor-only scrubbing the headline feature?
Because it removes the jank class entirely. The scroll-listener pattern (listen, read scrollY, compute progress, set style) executes on the main thread: it competes with rendering, can force layout, and drops frames on long pages. Scroll-driven timelines are consumed by the COMPOSITOR - the scrub advances on the same thread that paints, so the progress bar and parallax stay smooth even while the main thread is busy with scripts. That is also the honest boundary: anything needing main-thread decisions (data fetching per section, class toggles with side effects) still wants IntersectionObserver - the CSS timeline is for visual scrubbing, not for state machines.
How do scroll-driven animations pair with scroll snap?
Scrub plus lock. Scroll snap positions are the STOPS (each section clicks into place); the scroll-driven timeline is the MOTION between them (sections scale or fade in proportion to approach). Together they produce carousel and storytelling behavior - scrub-driven transforms while traveling, decisive locking at destinations - that previously needed JS scroll hijacking, with none of its accessibility costs: native scrolling, native keyboard support and native touch momentum all survive because nothing intercepted the scroll. The composed pattern is also a test for the base state: with timelines unsupported, the page must still snap and read perfectly - the animation is the garnish.