css-scroll-driven-animations
CSS Scroll-Driven Animations
When to use this
- You want a scroll progress bar, fade-in-on-enter, or sticky-header shrink effect and do not want to ship a scroll-linked JS library for it.
- You are building for Chromium-based browsers primarily (Chrome/Edge 115+) and can accept a graceful degraded-but-functional fallback on Firefox/Safari via
@supports. - You want an animation whose timeline is literally scroll position (not time) so it can never desync from the user's actual scroll, including fast flicks and scrollbar drags.
- You need per-element "enters/leaves viewport" triggers (
view-timeline) without an IntersectionObserver. - Do NOT use this when you need scrubbed pinning (element stays fixed while content scrolls past it) with precise pixel-level control across all browsers today: use
sticky-pinning-sequences(GSAP ScrollTrigger) instead, since native CSS has no pin primitive and Safari/Firefox support for scroll-driven animations is still incomplete as of 2024-2025.
Mental model
Normal CSS animations run on a time timeline: 0% to 100% maps to 0ms to duration ms, driven by the OS clock regardless of anything else on the page. animation-timeline replaces that clock with a different progress source. Two timeline types exist:
scroll() creates a timeline from a scroller's own scroll position: 0% is scrolled-to-start, 100% is scrolled-to-end, on whichever axis and whichever scroller you specify (scroll(root block), scroll(nearest inline), or a named scroller). This is for effects tied to the page's overall scroll, like a top progress bar.
view() creates a timeline from a single element's visibility inside its scrollport: 0% is the moment the element's first edge enters the scrollport, 100% is the moment its last edge exits. This is for "this specific card fades in as it scrolls into view" effects, and is the native replacement for IntersectionObserver-driven fade-ins. Because view() timelines are per-element, you can have hundreds of independently-timed elements with no JS overhead, since browsers compute and update these off the main thread as part of compositing.
animation-range further narrows which portion of that 0-100% timeline maps to the animation's own keyframes, using named regions: entry (element entering the scrollport), contain/cover (fully inside), exit (leaving). animation-range: entry 0% cover 40% means "start the keyframes the instant the element begins entering, finish them by the time it's 40% through being fully covered/visible." This lets you fire a fast reveal near the edges and hold steady mid-viewport, without hand-computing pixel offsets.
Because these run through the same engine as any CSS animation, only compositor-safe properties (transform, opacity, filter, clip-path in supporting engines) stay smooth; anything triggering layout will still jank exactly like a time-based animation would.