Scroll Timeline Scoping & Ranges
Part of Modern View Transitions & Scroll APIs.
Scroll-driven animation splits into two questions that CSS answers with two different mechanisms: which scroll motion should drive progress (scoping) and which slice of that motion the keyframes should occupy (ranges). Scoping is governed by scroll-timeline-name, view-timeline-name and timeline-scope; the active slice is governed by animation-range and its named phases entry, contain, cover and exit. Get the scoping wrong and the animation silently falls back to auto (document scroll) or never binds at all; get the range wrong and the motion plays against the wrong stretch of scroll. Both concerns resolve entirely on the compositor thread, so the reward for getting them right is scroll-linked motion that costs zero main-thread time per frame.
Concept definition
A timeline is a source of progress in the [0, 1] interval. A scroll timeline reports how far a scroll container has scrolled; a view timeline reports how far a subject element has travelled through its scrollport. animation-timeline binds one of these sources to an animation in place of wall-clock time, and animation-range restricts the animation to a named sub-interval of that source rather than the whole 0–100 %. Scoping decides where in the DOM a named timeline can be seen; ranges decide when along the scroll the keyframes are live.
Execution model: naming does not move work to the main thread
When you attach animation-timeline: scroll() or a named --progress identifier to an element animating transform and opacity, the browser registers the animated element’s layer with the compositor and records the scroll container’s geometry. On every scroll tick the compositor reads the raw scroll offset — already available without waking the main thread — maps it to a normalised progress value, clamps it to the active animation-range, and samples the keyframes. None of naming, scoping, or range selection changes this: they are resolved once at style time, then the per-frame path is pure compositor work.
Invalidations fire on the main thread only when the geometry changes: the scroll container resizes, content reflows above or below the subject, or the subject’s own box changes size. Each of these forces a re-registration pass. This is why growing a timeline-scope container’s height after the timeline is live can produce intermittent jank — the same failure profile described in scroll-driven animation patterns. Restricting keyframes to compositor-safe transform and opacity keeps every non-geometry frame off the main thread.
The subtle part is name resolution. A named timeline is looked up against the element’s ancestor chain. If the animated consumer is not a descendant of the declaring source, the lookup fails and animation-timeline resolves to none — the animation appears frozen at its animation-fill-mode value. timeline-scope fixes this by hoisting the name to a chosen ancestor so that both the source subtree and the consumer subtree can see it.
The four animation ranges along the scroll axis
The diagram below maps a subject element’s journey through the scrollport onto the four animation-range phases. Progress runs left to right, from the moment the element’s leading edge first touches the scrollport to the moment its trailing edge leaves.
cover is the safe default when you simply want an animation to play across the whole time the element is on screen. entry and exit isolate the arrival and departure motions — ideal for reveal-in / fade-out pairs. contain is the strictest: it is only non-empty when the subject is smaller than the scrollport, because a tall element is never wholly contained, and targeting contain on such an element yields a zero-length window that never animates.
Property and API reference
| Property / API | Accepted values | Compositing tier | Notes |
|---|---|---|---|
scroll-timeline-name |
--<ident> |
Composite | Names a scroll-progress timeline on a scroll container; visible to descendants only |
scroll-timeline-axis |
block, inline, x, y |
Composite | Which scroll axis feeds progress; defaults to block |
scroll-timeline |
<name> <axis> shorthand |
Composite | Sets name and axis in one declaration |
view-timeline-name |
--<ident> |
Composite | Names a visibility timeline on a subject element within its scrollport |
view-timeline-inset |
auto, <length-percentage>{1,2} |
Composite | Shrinks or grows the effective scrollport window used to measure visibility |
timeline-scope |
none, --<ident># |
Composite | Hoists one or more timeline names to this ancestor so non-descendant consumers can reference them |
animation-timeline |
auto, none, scroll(), view(), --<ident> |
Composite | Binds the animation to a timeline in place of time; pair with animation-duration: auto |
animation-range |
<range-name> <percentage> pairs |
Composite | Phases: cover, contain, entry, exit, entry-crossing, exit-crossing |
animation-range-start / -end |
normal, <range-name> <percentage> |
Composite | Longhands for asymmetric start/end bounds |
Annotated code examples
1. Named scroll timeline consumed by a sibling via timeline-scope
Intent: a fixed progress bar that is a sibling of the article — not a descendant — reads the article’s scroll progress. timeline-scope on the shared ancestor makes the name reachable.
/* The common ancestor hoists the name so siblings can share it. */
.layout {
timeline-scope: --article-progress;
}
/* Source: the scroller declares the named timeline. */
.article {
overflow-y: auto;
scroll-timeline-name: --article-progress;
scroll-timeline-axis: block;
}
/* Consumer: a sibling, not a descendant, binds to the hoisted name. */
.progress-bar {
transform-origin: left center;
transform: scaleX(0);
animation: fill linear both;
animation-timeline: --article-progress;
animation-duration: auto; /* required: scroll governs duration */
}
@keyframes fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.progress-bar {
animation: none;
animation-timeline: auto;
transform: scaleX(1); /* show completed state, no motion */
}
}
Rendering Impact:
transform(scaleX) — composite only. Name resolution happens once at style time; per-frame sampling stays on the compositor thread.
2. Targeting the entry range with view()
Intent: fade-and-rise each card only while it is arriving, so the motion is finished by the time the card is comfortably on screen.
/* Intent: play the reveal across the first half of the entry phase only. */
@keyframes reveal-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
animation: reveal-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 50%; /* arrival only, not the full pass */
animation-duration: auto;
}
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
animation-timeline: auto;
opacity: 1;
transform: none; /* resting, fully visible state */
}
}
Rendering Impact:
opacity+transform— composite only. Theentrywindow is computed from intersection geometry, recalculated on scrollport resize rather than per scroll tick.
3. Split arrival and departure across entry and exit
Intent: one element, two motions — rise in on entry, drift out on exit — driven by a single view timeline through asymmetric range longhands.
/* Intent: reuse one timeline for both an arrival and a departure motion. */
@keyframes drift {
0% { opacity: 0; transform: translateY(20px); }
25% { opacity: 1; transform: translateY(0); }
75% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-20px); }
}
.pane {
animation: drift linear both;
animation-timeline: view();
animation-range-start: entry 0%; /* start as it enters */
animation-range-end: exit 100%; /* finish as it leaves */
animation-duration: auto;
}
@media (prefers-reduced-motion: reduce) {
.pane {
animation: none;
animation-timeline: auto;
opacity: 1;
transform: none;
}
}
Rendering Impact:
opacity+transform— composite only. Spanningentrytoexitis equivalent in coverage tocoverbut lets you pin intermediate keyframes to precise visibility moments.
DevTools workflow
Use Chrome DevTools 115+ to confirm both the scoping and the range are behaving.
- Open DevTools → More tools → Animations. Scroll the page. A correctly bound timeline shows its progress bar tracking scroll position, not elapsed time. A bar that stays at 0 % while you scroll signals a failed name lookup — the consumer is not in scope of the source.
- Select the animated element and open the Styles pane. If
animation-timelineshows a strikethrough or resolves tonone, the named timeline is not visible from here; add or movetimeline-scopeto a common ancestor. - In Chrome 124+, enable the Animations panel’s scroll-driven view to see the active
animation-rangebounds overlaid on the subject in the viewport. Verify the highlighted band matches the phase you intended (entry,contain,cover,exit). - Open Performance, record a scroll, and inspect the Main thread lane. Zero
Recalculate StyleandLayoutevents during scroll confirms the timeline is compositor-driven. A recurringUpdate Layer Treeon each scroll tick points to geometry churn from a resizing scope container. - Open Rendering and enable Layer borders to confirm the animated subject holds its own compositor layer.
Failure modes and fixes
Problem: The consumer animation is frozen at its start value and never moves.
Root cause: The named timeline is declared on an element that is not an ancestor of the consumer, so animation-timeline: --name resolves to none.
Fix: Add timeline-scope: --name to the nearest common ancestor of the source and the consumer, hoisting the name into shared scope.
Problem: A contain-ranged animation never plays.
Root cause: The subject is taller than the scrollport, so it is never fully contained and the contain window has zero length.
Fix: Switch to cover, or reduce the subject’s height, or add view-timeline-inset to enlarge the effective window.
Problem: The reveal completes far too early, before the element looks fully on screen.
Root cause: entry ends the instant the element’s trailing edge crosses the scrollport’s leading edge, which for a tall element is well before it is centred.
Fix: Target cover instead of entry, or extend the range end to contain 100%, or apply a negative view-timeline-inset.
Problem: Progress jumps or resets after new content loads above the animated section.
Root cause: Inserting content changes the scroll container geometry, forcing a main-thread re-registration that re-derives progress.
Fix: Reserve space with an explicit min-height, or defer content insertion until after the scroll-linked section, mirroring the geometry-stability rule from profiling scroll-driven animations in DevTools.
Problem: The animation plays but with visible non-linear stutter relative to scroll speed.
Root cause: animation-timing-function defaulted to ease, layering a bezier curve over the scroll-to-progress mapping.
Fix: Set animation-timing-function: linear so scroll distance maps proportionally to progress.
Accessibility and reduced-motion notes
Scoping and ranges do not change the accessibility contract: a scroll-linked animation still moves content as the user scrolls, which can be disorienting for people who have requested reduced motion. Every named or anonymous timeline in this topic must be gated. The correct pattern resets animation-timeline to auto — decoupling the element from scroll entirely — and sets the resting transform/opacity so content is fully visible without any motion:
/* Intent: sever every scroll timeline and settle content to its resting state. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-timeline: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Rendering Impact: removes compositor scroll-binding entirely — no composite, paint, or layout work is scheduled for the disabled animations.
Because the range is what makes content appear (an entry-ranged reveal starts at opacity: 0), you must ensure the reduced-motion resting state is the visible end state, never the hidden start state. Treat the dedicated prefers-reduced-motion architecture guidance as the source of truth, and route vestibular concerns through the broader accessible motion architecture area.
Frequently asked questions
When do I need timeline-scope instead of a plain named timeline?
A named scroll-timeline or view-timeline is only visible to the element that declares it and its descendants. Use timeline-scope on a common ancestor when the animated consumer is a sibling or lives elsewhere in the tree — it hoists the name up so both the source and the consumer can reference it. The named vs anonymous scroll timelines guide works through the exact decision.
What is the difference between the cover and contain animation ranges?
cover spans the entire time any part of the element is within the scrollport, from the first pixel entering to the last pixel leaving. contain spans only the time the element is fully inside the scrollport — it is empty when the element is larger than the scrollport, because the element is never wholly contained.
Why does my view() animation never reach 100 percent?
If you target the entry range but the element is taller than the scrollport, entry completes before the element is fully visible, so a keyframe pinned to 100 % of entry fires earlier than expected. Switch to cover, or add a view-timeline-inset, so the active window matches the visual moment you intend.
Do named timelines run on the compositor like anonymous ones?
Yes. Naming a timeline changes only how the source is referenced in the cascade, not where interpolation runs. As long as the keyframes animate transform and opacity, both named and anonymous timelines are sampled on the compositor thread with no per-frame main-thread work.
Can one scroll timeline drive several animations at once?
Yes. Any number of elements can set animation-timeline to the same custom identifier, and each can target a different animation-range. This is the idiomatic way to build staggered reveals that all read from a single scroll source without duplicating scroll listeners.
Related
- Modern View Transitions & Scroll APIs — parent section covering the full browser motion API surface
- Named vs Anonymous Scroll Timelines — the decision matrix for
scroll()/view()versus named timelines plustimeline-scope - Scroll-Driven Animation Patterns — the production patterns these ranges plug into
- Hardware-Accelerated Properties — why restricting keyframes to
transformandopacitykeeps sampling on the compositor - Accessible Motion Architecture — reduced-motion and vestibular-safety strategy for every scroll-linked effect