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


Named timeline resolution across sibling branches via timeline-scope A DOM tree diagram. At the top, the body element declares timeline-scope: --page-scroll, marked as the hoist point. Below it are two sibling branches: a header containing a progress bar element, and a main element that is the scroll container declaring scroll-timeline: --page-scroll y. A dashed arrow from the scroller up to body shows the name being hoisted into scope; a second dashed arrow from the progress bar up to body shows its animation-timeline lookup resolving at the scope element. A caption notes that without timeline-scope the lookup would fail because the header branch contains no declaration. body — hoist point timeline-scope: --page-scroll header main — scroll container scroll-timeline: --page-scroll y .progress — consumer animation-timeline: --page-scroll name hoisted into scope lookup resolves at scope Without timeline-scope on body, .progress cannot see --page-scroll: the scroller is in a sibling branch, so the timeline stays inactive.

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:

  1. Walk the element’s inclusive ancestor chain — the element itself first, then its parent, and so on up to the root.
  2. At each step, check whether that element declares the name via scroll-timeline-name or view-timeline-name, or lists the name in timeline-scope.
  3. The nearest match wins. A timeline-scope match resolves to whichever descendant of the scope element declares that name.
  4. 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); }
}

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

  1. The animation shorthand resets animation-timeline. animation-timeline (and animation-range) cannot be expressed inside the animation shorthand, 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: auto restored, the animation runs as a time-based animation with the default duration of 0s and fill: both — often leaving the element frozen at the 100% keyframe rather than animating at all.

  2. scroll-timeline: --name resets the axis. The shorthand resets scroll-timeline-axis to block when omitted. If you set scroll-timeline-axis: x in one rule and scroll-timeline: --carousel in a later one, the axis snaps back to block.

  3. Dashed-ident or nothing. scroll-timeline-name: page-scroll is 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.

  4. Duplicate names under one scope null the timeline. Two descendants attaching the same name inside a timeline-scope subtree make it inactive for every consumer. Watch for this whenever a named-timeline component appears more than once per page.

  5. 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: something as an actual custom property has zero effect on timeline resolution.

  6. The declaring element must actually be a scroll container. scroll-timeline-name on an element without scrollable overflow produces an inactive timeline. An element with overflow: hidden still counts as a scroll container (programmatically scrollable); an element with overflow: visible never does. If a refactor removes overflow-y: auto, every consumer downstream goes static with no warning.

  7. 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 a ScrollTimeline or ViewTimeline object on animation.timeline, an unresolved name reports null or an inactive timeline with currentTime === null.

Performance checklist

  • Animate only transform and opacity on 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-scope at the nearest common ancestor, not on body by default: the smaller the scope subtree, the smaller the blast radius for duplicate-name collisions.
  • Keep timeline-scope lists short and explicit; auditing a three-name list beats reverse-engineering which of thirty names are still consumed.
  • Reset animation-timeline: auto inside your prefers-reduced-motion override — the reduced-motion implementation guide explains why animation: none alone 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 no null entries for elements you expect to be scroll-driven.

Up: Scroll-Driven & View Transition Implementation Patterns