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.