Named Timelines & timeline-scope
Named timelines separate where a scroll timeline is declared from where it is consumed. Instead of pointing animation-timeline at an anonymous scroll() or view() function — which can only reference the element’s own ancestor chain — you assign the timeline a <dashed-ident> name on the scroll container and reference that name from any element that can resolve it. The resolution rules are strict, the animation shorthand actively fights you, and timeline-scope exists precisely because the default lookup cannot cross sibling branches. This guide, part of Scroll-Driven & View Transition Implementation Patterns, covers the full naming syntax, the ancestor lookup algorithm, hoisting with timeline-scope, and the shorthand-reset pitfalls that silently break otherwise-correct declarations. For the underlying timeline model — progress mapping, axis semantics, and animation-range — see the CSS Scroll Timeline API guide.
Articles in this guide
- Sharing a Scroll Timeline Across Components with timeline-scope
- Anonymous vs Named Scroll Timelines: When Each Wins
Syntax reference
Three property families do all the work. Names must be <dashed-ident>s — custom-property-style identifiers beginning with --. A bare keyword like page-scroll is invalid and the whole declaration is dropped at parse time.
| Property | Values | Applies to | Notes |
|---|---|---|---|
scroll-timeline-name |
none | <dashed-ident># |
scroll containers | Names the element’s scroll progress timeline |
scroll-timeline-axis |
block | inline | x | y |
scroll containers | Default block; logical values flip with writing mode |
scroll-timeline |
<name> <axis>? |
scroll containers | Shorthand; omitted axis resets to block |
view-timeline-name |
none | <dashed-ident># |
any element | Names a view progress timeline tracking the element through its nearest scrollport |
view-timeline-axis |
block | inline | x | y |
any element | Default block |
view-timeline |
<name> <axis>? |
any element | Shorthand; view-timeline-inset is not included and must be set separately |
timeline-scope |
none | <dashed-ident># |
any element | Hoists name visibility to this element’s entire subtree |
animation-timeline |
auto | none | <dashed-ident> | scroll() | view() |
animated elements | The consuming side; a dashed-ident triggers the lookup algorithm |
Both scroll-timeline-name and view-timeline-name accept comma-separated lists, pairing positionally with scroll-timeline-axis / view-timeline-axis lists — the same list-matching behavior animation-name and animation-duration use. In practice one name per element is the maintainable default.
The axis values matter more than they look. block and inline are logical: in a horizontal-writing-mode document block means vertical scrolling, but in a vertical writing mode it means horizontal. x and y are physical and never flip. For internationalized layouts prefer the logical values; for a physically horizontal carousel that must stay horizontal regardless of locale, use x.
Minimal working example
A reading progress bar consuming a named timeline declared on the root scroller. Because html is an ancestor of every element, no timeline-scope is needed yet:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
/* 1. Name the root scroller's vertical scroll timeline */
html {
scroll-timeline: --page-scroll y;
}
/* 2. Consume it by name — html is an ancestor, lookup succeeds */
.progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: currentColor;
transform-origin: 0 50%;
/* shorthand FIRST, timeline longhand SECOND — order is load-bearing */
animation: grow linear both;
animation-timeline: --page-scroll;
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
</style>
</head>
<body>
<div class="progress"></div>
<main style="height: 400vh;">Long content…</main>
</body>
</html>
Swap the anonymous-function version (animation-timeline: scroll(root)) in and out and the rendered result is identical — the named form earns its keep the moment the scroller is not an ancestor of the consumer, or when several consumers need to agree on one timeline. The full build-out of this demo, including animation-range refinements, is in the reading progress bar walkthrough; the trade-off against anonymous functions is covered in scroll() vs view(): choosing the right timeline function.
Timeline lookup and scoping rules
When animation-timeline holds a dashed-ident, the browser resolves it with a deterministic algorithm:
- Walk the element’s inclusive ancestor chain — the element itself first, then its parent, and so on up to the root.
- At each step, check whether that element declares the name via
scroll-timeline-nameorview-timeline-name, or lists the name intimeline-scope. - The nearest match wins. A
timeline-scopematch resolves to whichever descendant of the scope element declares that name. - If no ancestor matches, the timeline is inactive: the animation’s current time is unresolved, no keyframe values apply, and the element renders its base styles. There is no error, no console warning — just a silently static element.
Two consequences follow directly. First, siblings are invisible to each other: a scroller in <main> cannot feed a progress bar in <header> no matter what you name things, because the header branch never encounters the declaration during the walk. Second, shadowing is possible: if a nested scroller re-declares --page-scroll, descendants of the inner scroller bind to the inner timeline while everything outside still binds to the outer one — occasionally useful, more often an accident.
timeline-scope fixes the sibling problem by hoisting. Declared on a common ancestor, it says: “the name --page-scroll is defined at this level, and its value is whatever timeline a descendant attaches to that name.”
body {
timeline-scope: --page-scroll; /* hoist point: whole subtree can resolve it */
}
main.scroller {
overflow-y: auto;
scroll-timeline: --page-scroll y; /* attaches the actual timeline to the name */
}
header .progress {
animation: grow linear both;
animation-timeline: --page-scroll; /* sibling branch — resolves via body */
}
One hard rule: if two or more descendants of the scope element attach the same name, the reference is ambiguous and the timeline becomes inactive for every consumer. The browser does not pick the first, the nearest, or the last — it nulls the timeline. Duplicate names inside one scope are the single most common failure when a component is rendered twice on a page; the cross-component sharing walkthrough shows how to generate per-instance names to avoid it.
animation-range composes with named timelines exactly as with anonymous ones: animation-range: entry 0% cover 50% on the consumer scopes which segment of the named timeline maps to the keyframes’ 0–100%. Range keywords like entry and exit only make sense against view timelines; against a named scroll timeline, use percentages or lengths.
Compositor-safe properties
Named timelines change where a timeline is resolved, not how it is sampled — the compositor-thread rules from the rendering pipeline for scroll animations apply unchanged:
| Property animated on the named timeline | Thread | Verdict |
|---|---|---|
transform |
Compositor | Safe — use for progress bars, dots, parallax layers |
opacity |
Compositor | Safe — use for fades tied to a distant scroller |
filter |
Compositor in Chromium, partially elsewhere | Test per engine |
width, height, inset |
Main thread (layout) | Avoid — every scroll frame forces layout |
background-color, color |
Main thread (paint) | Avoid on long timelines |
One subtlety specific to hoisted timelines: because the consumer and the scroller can be far apart in the tree, it is easy to accidentally animate a property on a consumer that sits inside a will-change-promoted ancestor stack, multiplying layer memory. Audit the Layers panel after wiring a timeline-scope pattern, not just after writing the keyframes.
Common implementation patterns
Pattern 1: progress bar for a scroller elsewhere in the tree
The canonical timeline-scope use case — a fixed toolbar reflecting the scroll position of an independent pane, common in docs sites and chat UIs:
.layout {
display: grid;
grid-template-rows: auto 1fr;
timeline-scope: --article-scroll; /* common ancestor hoists the name */
}
.article-pane {
overflow-y: auto; /* the actual scroll container */
scroll-timeline: --article-scroll y;
}
.toolbar .progress {
transform-origin: 0 50%;
animation: grow linear both;
animation-timeline: --article-scroll; /* lives in the toolbar branch */
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Pattern 2: carousel position dots
Each slide declares its own named view timeline; each dot consumes the matching name. timeline-scope on the carousel root makes the slide names visible to the dots branch:
.carousel {
timeline-scope: --slide-1, --slide-2, --slide-3;
}
.track {
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.slide:nth-child(1) { view-timeline: --slide-1 inline; }
.slide:nth-child(2) { view-timeline: --slide-2 inline; }
.slide:nth-child(3) { view-timeline: --slide-3 inline; }
.dot {
opacity: 0.35;
animation: dot-active linear both;
animation-range: contain 0% contain 100%;
}
.dot:nth-child(1) { animation-timeline: --slide-1; }
.dot:nth-child(2) { animation-timeline: --slide-2; }
.dot:nth-child(3) { animation-timeline: --slide-3; }
@keyframes dot-active {
0%, 100% { opacity: 0.35; transform: scale(1); }
50% { opacity: 1; transform: scale(1.3); }
}
Each dot brightens exactly while its slide is fully contained in the scrollport — no scroll event listener, no IntersectionObserver, no state management.
Pattern 3: one scroller, many consumers
A single named timeline can drive any number of animations at different ranges — a header that shrinks, a table of contents that highlights, and a back-to-top button that fades in, all sampling --page-scroll at different animation-range windows. Declare the name once on the scroller, hoist once if needed, and let each consumer pick its own range. Centralizing on one named timeline instead of scattering scroll(root) calls also gives you a single place to retarget every effect if the scroll container changes — say, when a redesign moves scrolling from the root to an inner wrapper.
Browser support and @supports guard
| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
scroll-timeline-name / view-timeline-name, shorthands |
115 | Behind a pref (Nightly) | In active development (Technology Preview) |
timeline-scope |
116 | Behind a pref (Nightly) | In active development |
animation-timeline: <dashed-ident> |
115 | Behind a pref (Nightly) | In active development |
Note the one-version gap: Chrome 115 shipped named timelines without timeline-scope, which arrived in 116. Feature-detect them independently — a same-subtree named timeline can work where a hoisted one cannot:
/* Named timelines where scroller is an ancestor of the consumer */
@supports (animation-timeline: --check) {
.progress {
animation: grow linear both;
animation-timeline: --page-scroll;
}
}
/* Cross-branch patterns need the hoisting property too */
@supports (timeline-scope: --check) {
.layout { timeline-scope: --article-scroll; }
}
Keep the base styles presentable without any timeline: a progress bar that is simply hidden, dots at uniform opacity, a header at full size. Engines that fail the @supports test — and engines where the pref is off — then degrade to a static but correct UI, following the same discipline as the site-wide progressive enhancement strategy.
Gotchas and failure modes
-
The
animationshorthand resetsanimation-timeline.animation-timeline(andanimation-range) cannot be expressed inside theanimationshorthand, so the shorthand resets both to their initial values. Declaring them before the shorthand in the same rule means they are silently wiped:/* BROKEN — the shorthand resets animation-timeline back to auto */ .bar { animation-timeline: --page-scroll; animation: grow linear both; } /* CORRECT — longhands after the shorthand */ .bar { animation: grow linear both; animation-timeline: --page-scroll; animation-range: 0% 100%; }The failure is doubly sneaky because with
animation-timeline: autorestored, the animation runs as a time-based animation with the default duration of0sandfill: both— often leaving the element frozen at the 100% keyframe rather than animating at all. -
scroll-timeline: --nameresets the axis. The shorthand resetsscroll-timeline-axistoblockwhen omitted. If you setscroll-timeline-axis: xin one rule andscroll-timeline: --carouselin a later one, the axis snaps back toblock. -
Dashed-ident or nothing.
scroll-timeline-name: page-scrollis a parse error and the declaration is dropped — but the rest of the rule still applies, so the element looks half-configured rather than obviously broken. Lint for--prefixes. -
Duplicate names under one scope null the timeline. Two descendants attaching the same name inside a
timeline-scopesubtree make it inactive for every consumer. Watch for this whenever a named-timeline component appears more than once per page. -
Names do not cascade like custom properties. A dashed-ident timeline name looks like a custom property but is not one: it does not inherit, cannot be read with
var(), and participates only in the tree-walk lookup. Setting--page-scroll: somethingas an actual custom property has zero effect on timeline resolution. -
The declaring element must actually be a scroll container.
scroll-timeline-nameon an element without scrollable overflow produces an inactive timeline. An element withoverflow: hiddenstill counts as a scroll container (programmatically scrollable); an element withoverflow: visiblenever does. If a refactor removesoverflow-y: auto, every consumer downstream goes static with no warning. -
Inactive means invisible, not broken. Every failure above ends in the same state: an animation attached to an inactive timeline, applying no keyframe values. Verify with
document.getAnimations()— a healthy binding reports aScrollTimelineorViewTimelineobject onanimation.timeline, an unresolved name reportsnullor an inactive timeline withcurrentTime === null.
Performance checklist
- Animate only
transformandopacityon named timelines; the compositor fast path is identical to anonymous timelines, and so are the main-thread penalties for layout properties. - Prefer one named timeline with many consumers over many anonymous
scroll()bindings to the same scroller — same runtime cost, far cheaper to retarget when the DOM changes. - Declare
timeline-scopeat the nearest common ancestor, not onbodyby default: the smaller the scope subtree, the smaller the blast radius for duplicate-name collisions. - Keep
timeline-scopelists short and explicit; auditing a three-name list beats reverse-engineering which of thirty names are still consumed. - Reset
animation-timeline: autoinside yourprefers-reduced-motionoverride — the reduced-motion implementation guide explains whyanimation: nonealone does not detach scroll bindings. - After wiring a hoisted timeline, record a scroll in the Performance panel: consumers should show compositor-thread animation ticks and zero per-frame style recalcs on the scope element.
- Verify each named binding once in the console:
document.getAnimations().map(a => a.timeline)should contain nonullentries for elements you expect to be scroll-driven.
Related
- Building Scroll Progress Indicators — the full progress-indicator guide these named-timeline patterns plug into
- Animating Sticky Headers on Scroll Direction Change — header effects that pair naturally with a hoisted page timeline
- @supports Guard Recipes for animation-timeline — feature-detection patterns including the named-timeline and timeline-scope probes
- How to Polyfill Scroll Timeline for Safari — polyfill coverage and limits for named timelines in engines still shipping support
- Smooth Parallax Scrolling Without JavaScript — anonymous-timeline parallax that becomes retargetable when converted to a named timeline