Scroll-Driven Animation Patterns
Part of Modern View Transitions & Scroll APIs.
Scroll-driven animations bind animation progress directly to a scroll position, replacing JavaScript scroll listeners with a browser-native mechanism that runs entirely on the compositor thread. The animation-timeline property — introduced in Chrome 115, Firefox 136, and Safari 18 — accepts either scroll() (progress through a scroll container) or view() (element visibility within a viewport) as its value. The result is declarative, interruptible, 60 fps motion with zero main-thread overhead for properties restricted to transform and opacity.
Concept Definition
A scroll-driven animation is any CSS animation whose playback progress is controlled by scroll position rather than time. Where a conventional animation-duration: 1s plays from 0 % to 100 % over one second regardless of user input, an animation-timeline: scroll() animation plays from 0 % to 100 % as the scroll container moves from its start edge to its end edge. The browser never invokes the JavaScript engine for this mapping — the mapping is resolved in the same compositor pass that paints the frame, which is why restricting animated properties to compositor-safe transforms and opacity is so important: it keeps the entire pipeline off the main thread.
The two descriptor functions solve different problems. scroll() tracks the aggregate scroll offset of a container — useful for reading-progress bars and parallax backgrounds. view() tracks how much of a specific element is inside the scroll port — useful for entrance reveals and staggered lists. Both accept an optional axis argument (block, inline, x, y) that defaults to block.
Execution Model
When the browser encounters animation-timeline: scroll() or animation-timeline: view(), it registers the animated element’s layer with the compositor and records the scroll container’s current geometry. During each scroll event the compositor reads the raw scroll offset (already available without touching the main thread), maps it to a normalized [0, 1] progress value, and samples the @keyframes at that point. The sampled values — exclusively transform and opacity — are applied directly to the GPU layer without triggering style recalculation, layout, or paint.
Invalidations occur when the scroll container’s geometry changes (e.g., the page height increases because new content loads below). That forces a re-registration pass on the main thread, which is why dynamically growing containers attached to scroll() timelines can cause intermittent jank. The fix is to reserve explicit height before animation starts.
The main thread is re-entered only when:
- Animated properties other than
transformoropacityare used. - A custom property (
@property) is used as an animation target without registering it withinherits: false. - A JavaScript
Animationobject wraps the scroll timeline and callscommitStyles().
Property and API Reference
| Property / API | Accepted Values | Compositing Tier | Notes |
|---|---|---|---|
animation-timeline |
scroll(), view(), auto, none, <custom-ident> |
Composite (transform/opacity) | Must set animation-duration: auto when using scroll timelines |
scroll() descriptor |
[<scroller>] [<axis>] |
Composite | <scroller>: root, nearest, self; <axis>: block, inline, x, y |
view() descriptor |
[<axis>] [<view-timeline-inset>] |
Composite | Inset shifts the effective viewport window inward |
animation-range |
<timeline-range-name> <percentage> |
Composite | Values: cover, contain, entry, exit, entry-crossing, exit-crossing |
animation-timing-function |
linear (mandatory) |
Composite | Any non-linear easing distorts the scroll-to-progress mapping |
scroll-timeline-name |
--<ident> |
Composite | Named timeline on a container; consumed by children via animation-timeline: --name |
view-timeline-name |
--<ident> |
Composite | Named view timeline; lets a parent observe a child’s visibility |
animation-fill-mode |
both |
Composite | Keeps start/end keyframe values when scroll is at 0 % or 100 % |
Annotated Code Examples
Reading-progress indicator with scroll(root)
/* Intent: bind a thin progress bar's width to page scroll offset. */
@keyframes reading-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.reading-bar {
position: fixed;
top: 0; left: 0;
width: 100%; height: 4px;
background: oklch(55% 0.22 270); /* site violet */
transform-origin: left center;
transform: scaleX(0);
animation: reading-progress linear both;
animation-timeline: scroll(root block); /* tracks root block-axis scroll */
animation-duration: auto; /* required: duration governed by scroll */
}
@media (prefers-reduced-motion: reduce) {
.reading-bar { animation: none; transform: scaleX(1); } /* always full */
}
Rendering Impact — Composite.
scaleXis resolved on the GPU layer. The root scroller is already tracked by the compositor, so no additional layout or paint passes are triggered.
Viewport reveal with view() and animation-range
/* Intent: fade-and-rise each card as it enters the viewport's lower quarter. */
@keyframes reveal-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
animation: reveal-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 50%; /* plays while element moves from 0–50 % into view */
animation-duration: auto;
}
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
opacity: 1;
transform: none;
}
}
Rendering Impact — Composite.
view()uses the element’s intersection geometry, computed once per scroll port resize, not per scroll event. Interpolation ofopacityandtransformstays on the compositor thread.
Named scroll timeline consumed by a child element
/* Intent: let a sidebar track the scroll progress of a specific article section. */
.article-section {
scroll-timeline-name: --section-progress;
scroll-timeline-axis: block;
}
.sidebar-indicator {
animation: fill-indicator linear both;
animation-timeline: --section-progress;
animation-duration: auto;
}
@keyframes fill-indicator {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
.sidebar-indicator { animation: none; transform: scaleY(1); }
}
Rendering Impact — Composite. Named timelines keep the relationship between container and observer in the compositor’s scroll data, avoiding any JS bridge.
DevTools Workflow
Use Chrome DevTools (version 115+) or Firefox DevTools to audit scroll-driven timelines without guesswork.
Chrome DevTools
- Open DevTools → More tools → Animations (or press Shift+Escape and open the Animations panel from the drawer).
- Scroll the page. The Animations panel shows scroll-timeline animations as blocks whose progress bar tracks scroll position rather than elapsed time. Animations that are not moving when you scroll indicate a misconfigured
animation-timelineor an incorrect scroll container reference. - Open Performance and record a scroll session. Look at the Frames row. If frames stay in the compositor lane (green) with no purple Layout or yellow Scripting bars, the animation is fully off the main thread.
- Open Rendering (three-dot menu → More tools → Rendering) and enable Layer borders (cyan overlay = compositor layer). Confirm your animated element has its own layer. If it does not,
will-change: transformon the element forces promotion — use sparingly. - Enable the Animations panel’s Scroll-driven animations checkbox (Chrome 124+) to see
animation-rangebounds overlaid on the element in the viewport.
Firefox DevTools
- Open DevTools → Inspector → Animations (the stopwatch icon in the Rules panel sidebar).
- With the animated element selected, scroll the page. The progress slider tracks live scroll position.
- Use Performance → Record to verify no Reflow markers appear during scroll.
What to look for in the flame chart
- Zero
Recalculate Styleevents during scroll = good. Update Layer Treeevents once per resize = acceptable.- Repeated
Layoutevents during scroll = an animated property is triggering layout. Audit@keyframesand remove any geometry-changing properties.
Failure Modes and Fixes
Problem: Animation does not play at all.
Root cause: animation-duration is left at its default (0s) instead of auto. Time-based animations with zero duration are instantly complete; scroll-driven animations require auto to indicate that duration is controlled by scroll.
Fix: Add animation-duration: auto; to every rule using animation-timeline.
Problem: Animation motion is non-linear and jerky.
Root cause: animation-timing-function defaults to ease, which applies a cubic-bezier curve on top of scroll progress, producing non-proportional motion.
Fix: Set animation-timing-function: linear. If you want easing within a keyframe range, use linear() with step values rather than a traditional easing keyword.
Problem: Continuous layout recalculations during scroll.
Root cause: A property other than transform or opacity is included in @keyframes — for example, height, top, left, or margin. The compositor cannot handle these and escalates to the main thread.
Fix: Restrict @keyframes to transform and opacity only. Simulate size changes with transform: scale() rather than width/height. See avoiding layout thrashing in CSS animations for the full property audit checklist.
Problem: scroll() timeline lags or jumps on dynamically growing pages.
Root cause: The scroll container’s total scrollable height changes after the timeline is registered, invalidating the normalized progress mapping.
Fix: Reserve an explicit min-height on the scroll container before animation starts. Avoid appending large content blocks below animated sections without first pausing the animation.
Problem: Animation state is wrong after a View Transitions API navigation.
Root cause: After a cross-document or same-document view transition, the new document’s scroll position starts at 0, but the old animation state may persist in the CSS cascade if animation-fill-mode: both is set.
Fix: Reset animation-fill-mode to none on elements that should not retain their animated state across transitions, or use @starting-style (covered in CSS @starting-style & Entry Effects) to explicitly set the pre-transition state.
Accessibility and Reduced-Motion Notes
Scroll-driven animations present a specific accessibility risk beyond conventional animations: because they are tied to scroll position rather than time, users who rely on keyboard navigation or switch access control scroll independently of mouse gestures, and they may not expect visual changes to occur as they move through a page. The prefers-reduced-motion: reduce media query must therefore gate every scroll-driven animation, not just timed ones.
The correct pattern is to disable the animation entirely and set the final (fully visible) state as the default:
@media (prefers-reduced-motion: reduce) {
/* Reset all scroll-driven animations site-wide */
*,
*::before,
*::after {
animation-timeline: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
For reveal animations specifically, ensure that opacity: 1 and transform: none are the resting state so content remains visible even when the animation is disabled. Do not rely on animation-fill-mode: forwards to make content visible under reduced-motion, because the animation itself is disabled.
Frequently Asked Questions
Can scroll-driven animations run on mobile Safari?
Native support requires Safari 18+. Detect support with CSS.supports('animation-timeline', 'scroll()') before applying timelines. For older Safari, fall back to the passive-listener pattern shown in driving animations with scroll-timeline polyfills — it writes scroll progress to --scroll-progress as a custom property consumed by @keyframes.
Why must I set animation-timing-function: linear for scroll-driven animations?
Scroll-driven timelines map scroll position directly to animation progress. Any non-linear easing distorts that mapping, producing motion that does not correspond to scroll velocity. linear preserves a 1:1 relationship between pixels scrolled and animation progress. If you want easing on a sub-range, define additional keyframe stops with linear() step values instead.
What is the difference between scroll() and view() descriptors?
scroll() ties animation progress to the aggregate scroll offset of a named or nearest scroll container, reaching 100 % when the container is fully scrolled. view() ties progress to how much of a specific element is inside the scroll port, using animation-range to define which phase of entry or exit drives the animation. Use scroll() for page-level indicators and parallax; use view() for element-level entrance and exit effects.
How do I prevent scroll-driven animations from causing layout shifts?
Reserve explicit dimensions on animated containers before animation starts. Animate only transform and opacity — never width, height, top, or left. Pair with compositor-only property optimization patterns to stay entirely within the composite layer.
Is animation-timeline: view() better than IntersectionObserver?
view() is superior for continuous, scroll-linked progress tracking where the animation state should mirror exact scroll position. IntersectionObserver is better for discrete state changes — triggering a one-shot animation the first time an element enters the viewport. In practice, use view() for the CSS motion and IntersectionObserver for any imperative JS logic (adding a class, lazy-loading content) that should fire once.
Related
- Modern View Transitions & Scroll APIs — parent section covering the full browser motion API surface
- View Transitions API Implementation — coordinate scroll-timeline state with same-document and cross-document view transitions
- CSS @starting-style & Entry Effects — combine with
view()timelines for seamless element entry sequences - Container Query Motion Triggers — trigger animations based on container size rather than scroll position
- Driving Animations with Scroll-Timeline Polyfills — bridge the gap for Safari 17 and below with a main-thread fallback