How CSS timeline-scope Hoists Scroll Timelines
The CSS timeline-scope property allows developers to
hoist named scroll and view progress timelines from their default local
context up to a shared ancestor element. By default, CSS scroll-driven
animations restrict named timelines to the declaring element and its DOM
descendants. By declaring timeline-scope on a common
ancestor, you expose that timeline to sibling elements, parents, or
entirely separate branches within the shared subtree, enabling
coordinated cross-component scroll animations without JavaScript.
The Default Timeline Scoping Limitation
When creating scroll-driven animations in CSS, you define a named
timeline on a scroll container using properties like
scroll-timeline-name or
view-timeline-name:
.scroller {
overflow-y: scroll;
scroll-timeline-name: --page-timeline;
}Under normal CSS scoping rules, --page-timeline is
strictly scoped to .scroller and its descendants. If you
want an element outside of .scroller—such as a fixed
header, a sibling progress bar, or a parent navigation bar—to animate
based on .scroller's scroll position, the animation fails
to bind because the timeline name is out of scope.
How timeline-scope Hoists the Timeline
The timeline-scope property solves this scoping boundary
problem by operating similarly to variable declaration hoisting. When
applied to an ancestral DOM element, it reserves one or more timeline
names at that higher level.
When an ancestor declares:
.common-ancestor {
timeline-scope: --page-timeline;
}The browser attaches the identifier --page-timeline to
.common-ancestor. When a descendant element deeper in the
tree defines scroll-timeline-name: --page-timeline, the
browser matches this definition to the hoisted scope rather than
confining it locally.
Consequently, any element contained within
.common-ancestor gains the ability to reference
--page-timeline using animation-timeline.
Practical Implementation
Consider a layout where a progress indicator sits inside a header, parallel to the actual scrolling container:
<div class="layout-wrapper">
<header>
<div class="progress-bar"></div>
</header>
<main class="content-scroller">
<!-- Long scrollable content -->
</main>
</div>To drive the .progress-bar animation using the scroll
position of .content-scroller:
/* 1. Hoist the timeline name to the common ancestor */
.layout-wrapper {
timeline-scope: --content-timeline;
}
/* 2. Define the timeline on the scrollable container */
.content-scroller {
overflow-y: scroll;
scroll-timeline-name: --content-timeline;
scroll-timeline-axis: block;
}
/* 3. Consume the timeline in a separate branch */
.progress-bar {
scale: 0 1;
transform-origin: left;
animation: fill-bar linear;
animation-timeline: --content-timeline;
}
@keyframes fill-bar {
to {
scale: 1 1;
}
}Without timeline-scope: --content-timeline on
.layout-wrapper, .progress-bar would not
recognize --content-timeline, rendering the animation
inert.
Multiple Timelines and Precedence Rules
The timeline-scope property accepts a comma-separated
list of custom identifiers (names prefixed with --):
.dashboard {
timeline-scope: --panel-one-timeline, --panel-two-timeline;
}If multiple descendants declare a timeline matching a hoisted name, the browser resolves the conflict based on tree order: the timeline defined on the last matching element in document order is bound to the hoisted scope. If no descendant declares the hoisted timeline name, the timeline remains unattached, and animations referencing it will not run.