Sharing a Scroll Timeline Across Components with timeline-scope

By default a named scroll timeline is only visible to descendants of the element that declares it — a progress bar in your <Toolbar> component cannot see a timeline named on the scroller inside <ArticlePane>, because the lookup walks ancestors and never crosses into a sibling branch. timeline-scope solves exactly this: declared on a common ancestor, it hoists the name so the entire subtree can resolve it. This walkthrough — part of Scroll-Driven & View Transition Implementation Patterns — wires the pattern end to end in three CSS declarations, then covers the component-framework wrinkles and a verification routine that catches the silent failure modes.

When to use this approach

  • Scroller and consumer live in different components / sibling branches — the core case. A toolbar progress bar fed by an article pane, carousel dots fed by a slide track, a sidebar map fed by the main column. timeline-scope is the only pure-CSS way to bridge branches.
  • Scroller is an ancestor of the consumer — you do not need timeline-scope. A plain named timeline (or even an anonymous scroll() function) already resolves; adding a scope declaration is harmless but noise.
  • Consumer needs the nearest scroller, whatever it is — use anonymous scroll(nearest) instead and skip naming entirely; the anonymous vs named comparison covers that decision in depth.
  • Component renders multiple times per page — the pattern still works but each instance needs its own name (or its own scope root), because duplicate names inside one scope make the timeline inactive for everyone. Step 4 shows the per-instance naming fix.
  • You were about to write a scroll event listener — a listener that only mirrors scroll progress into a style is exactly what this replaces, with compositor-thread smoothness the main thread cannot match.

Three steps: declare, hoist, consume A horizontal flow of three numbered boxes. Step 1: the scroll container declares scroll-timeline with the name --shared and axis y. Step 2: the common ancestor declares timeline-scope --shared, making the name visible to its whole subtree. Step 3: the distant consumer sets animation-timeline --shared plus its keyframe animation. Arrows connect step 1 to step 2 labelled name attaches upward, and step 2 to step 3 labelled subtree resolves downward. 1. Declare .article-pane { scroll-timeline: --shared y; } name attaches upward 2. Hoist .layout { timeline-scope: --shared; } subtree resolves downward 3. Consume .progress { animation-timeline: --shared; } Requirement: the hoist element must be a common ancestor of the scroller and every consumer — and hold exactly one --shared declarer.

Implementation

The running example: a two-pane app shell where .article-pane scrolls independently and .toolbar (a sibling component) shows a progress bar for it.

<div class="layout">
  <div class="toolbar">
    <div class="progress" aria-hidden="true"></div>
  </div>
  <div class="article-pane">
    <article>Long content…</article>
  </div>
</div>

Step 1: Declare the named timeline on the scroll container

.article-pane {
  overflow-y: auto;                 /* must be a real scroll container */
  scroll-timeline: --shared y;      /* name + physical vertical axis */
}

Two requirements hide in these two lines. The element must have scrollable overflow — overflow-y: auto or scroll, with content that actually overflows; an element that never overflows produces a timeline that is technically attached but reports no progress. And the name must be a <dashed-ident>: --shared parses, shared is silently dropped. If the pane scrolls horizontally in some layouts, prefer the logical block/inline axis keywords over y — the scroll timeline API guide covers how logical axes track writing mode.

Step 2: Add timeline-scope on the common ancestor

.layout {
  timeline-scope: --shared;
}

This is the hoist. timeline-scope declares that the name --shared exists at .layout’s level; the actual timeline is whatever single descendant attaches to that name. Pick the nearest common ancestor of scroller and consumers, not body: a tight scope keeps the name from colliding with a second --shared declared by an unrelated component elsewhere on the page. Multiple names are comma-separated (timeline-scope: --shared, --minimap), and the property only affects name visibility — it adds no layout, stacking, or containment behavior of its own.

Step 3: Consume it with animation-timeline on the distant element

.progress {
  height: 4px;
  background: currentColor;
  transform-origin: 0 50%;
  animation: grow linear both;        /* shorthand first… */
  animation-timeline: --shared;       /* …timeline longhand second */
}

@keyframes grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

Declaration order is load-bearing: the animation shorthand resets animation-timeline (and animation-range) to their initial values, so the longhand must come after it. The lookup now walks up from .progress, finds --shared in .layout’s timeline-scope, and binds to the pane’s scroll position — the same mechanism the single-scroller reading progress bar uses, generalized across branches. Any number of additional consumers under .layout can reference --shared with their own keyframes and animation-range windows at no extra cost.

Step 4: Component-framework considerations

The pattern is pure CSS, so it survives SSR and hydration untouched — but component architecture introduces three practical wrinkles.

Ownership: the scope belongs to the layout, not the widgets. The component that owns the common ancestor (the app shell, the layout component) should declare timeline-scope, because it is the only component that knows it contains both ends. Treat the timeline name as part of the layout’s public contract, alongside its slots/children API.

Scoped-style systems pass dashed-idents through — which means collisions are yours to manage. CSS Modules, Vue SFC scoped styles, and Svelte’s style scoping rewrite selectors, not dashed-idents; --shared emerges from every build pipeline unrenamed. That keeps declaration and consumption in different components in sync for free, but it also means two instances of the same component emit the same name. Inside one scope, duplicate names make the timeline inactive for all consumers, so multi-instance components should derive per-instance names from a prop or id and set them inline:

// React: one shared name prop wires all three roles per instance
function ArticleShell({ id, children }) {
  const name = `--article-scroll-${id}`;
  return (
    <div className="layout" style={{ timelineScope: name }}>
      <div className="toolbar">
        <div
          className="progress"
          style={{ animationTimeline: name }}
        />
      </div>
      <div
        className="article-pane"
        style={{ scrollTimeline: `${name} y` }}
      />
    </div>
  );
}

Inline styles sidestep the stylesheet entirely, so the shorthand-reset ordering problem cannot bite here — but keep the animation: grow linear both part in the stylesheet and only the animationTimeline override inline, or the inline shorthand will reset the inline longhand depending on property order in the style object’s serialization.

Portals and teleports break the pattern. React portals and Vue <Teleport> move the consumer’s DOM node outside the layout subtree; the CSS lookup follows the DOM tree, not the component tree, so a teleported progress bar can no longer see the scope. Either widen the scope to an ancestor of the portal target or fall back to a JavaScript bridge for that one consumer.

Verification

  1. Computed styles, both ends. Elements panel → select .article-pane → Computed → confirm scroll-timeline-name is --shared (not none). Select .progress → confirm animation-timeline computes to --shared — if it shows auto, the animation shorthand reset it after your longhand.

  2. Confirm the binding is live. Console:

    const anim = document.querySelector('.progress').getAnimations()[0];
    console.log(anim.timeline?.constructor.name); // "ScrollTimeline"
    console.log(anim.timeline?.currentTime);      // CSSUnitValue, not null

    A null timeline or a currentTime of null means the name never resolved or resolved ambiguously — check the scope placement and hunt for duplicate declarers.

  3. Scroll the pane, not the page. Drag .article-pane’s scrollbar and watch the bar move; then scroll the root document and confirm the bar does not move. If root scrolling drives it, the consumer bound to a different timeline than you think.

  4. Performance check. Performance panel → record a scroll of the pane → animation ticks for .progress should appear on the compositor track with no per-frame Recalculate Style on .layout.

  5. Regression-proof it. In a Playwright test, set the pane’s scrollTop, wait a frame, and assert getComputedStyle(progress).transform changed — this catches the all-too-easy refactor where someone removes overflow-y: auto and every consumer silently freezes.

Edge cases and gotchas

Two declarers, zero winners. If two descendants of .layout both attach --shared, the scoped name becomes inactive for every consumer — the browser refuses to guess. This is the classic “works with one card, breaks with two” bug in list-rendered components; fix with per-instance names (Step 4).

The scope element cannot be the consumer’s non-ancestor. timeline-scope only helps elements inside its subtree. A consumer in a modal appended to body needs the scope on body (or higher) — at which point name collisions across the whole page become your problem.

Nested scopes shadow outward ones. A timeline-scope: --shared on an inner wrapper re-declares the name for its subtree; consumers inside bind to whatever attaches within the inner scope (or go inactive if nothing does), even though a perfectly good outer --shared exists. Deliberate shadowing is a feature; accidental shadowing looks like a dead progress bar.

Shadow DOM boundaries. Timeline names follow the flattened tree, and support for crossing shadow roots varies. Keep declarer, scope, and consumers in the same tree scope where you can, or pass progress across the boundary with a custom property driven by a WAAPI ScrollTimeline instead.

Display toggles kill the timeline. Setting the scroller to display: none (a collapsed pane, an inactive tab) makes its timeline inactive; consumers hold their base styles rather than their last animated frame. If the bar must persist while the pane is hidden, snapshot the progress into a custom property before hiding.

Reduced motion still applies. A hoisted timeline is still an animation; your prefers-reduced-motion override should reset animation-timeline: auto on consumers like any other scroll-driven element.

Browser-specific notes

Chrome / Edge (Chromium). Named timelines shipped in 115, but timeline-scope landed in 116 — a real gap: on 115 the declare-and-consume steps work when the scroller is an ancestor, while the hoisted pattern silently fails. Feature-detect the two independently:

@supports (timeline-scope: --check) {
  /* cross-component pattern is safe here */
}

Firefox. Scroll-driven animations, including named timelines and timeline-scope, are implemented behind a pref (Nightly). Ship the @supports guard and a static fallback; do not rely on UA sniffing.

Safari. Scroll-driven animation support is in active development (Technology Preview). The scroll-timeline polyfill covers named timelines and timeline-scope well enough for progress-bar patterns, but it samples on the main thread — budget for that on long pages.

Everything else. With the @supports guard in place, unsupported engines render the static base styles. If the progress indication is functionally important rather than decorative, pair the guard with an IntersectionObserver-based fallback as described in the scroll-driven animations vs IntersectionObserver comparison.


Up: Named Timelines & timeline-scope