Anonymous vs Named Scroll Timelines: When Each Wins

animation-timeline accepts two fundamentally different kinds of value: anonymous timeline functions — scroll() and view() — resolved on the spot relative to the animated element, and named timelines declared elsewhere with scroll-timeline-name / view-timeline-name and referenced by <dashed-ident>. Both drive the same underlying progress timelines; what differs is coupling, reach, and how failures present when something moves in the DOM. This comparison — part of the Named Timelines & timeline-scope guide within Scroll-Driven & View Transition Implementation Patterns — gives you decision criteria, a refactoring path in each direction, and the cascade and debugging differences that decide real incidents.

When to use this approach

Choosing between an anonymous function and a name is a coupling decision, not a capability one:

  • Anonymous scroll() / view() wins for self-contained effects. A card that fades itself in with animation-timeline: view(), a hero that parallaxes against scroll(root) — declaration and consumption on the same element, zero coordination, copy-paste portable between projects. Which function to use is its own question, covered in scroll() vs view(): choosing the right timeline function.
  • Anonymous wins when “nearest scroller, whatever it is” is the actual requirement. scroll(nearest) and view() rebind automatically if the component is dropped into a different scroll container. That looseness is a feature in design-system components shipped to unknown DOM.
  • Named wins the moment two elements must agree on one timeline. Several consumers sampling the same scroller at different ranges, or a consumer in a different branch than the scroller (via timeline-scope), cannot be expressed with anonymous functions at all — scroll() can only see the element’s own ancestor chain, and always picks its nearest scroller, which may differ per consumer.
  • Named wins for explicitness at scale. A dashed-ident is greppable; scroll(nearest) means “whichever ancestor happens to scroll today.” When a wrapper gains overflow: auto in a refactor, every scroll(nearest) beneath it silently retargets; a named binding keeps pointing at the declared scroller or fails visibly.
  • Anonymous wins on minimum-version support. scroll()/view() and named lookup both shipped with Chrome 115, but the cross-branch timeline-scope half of the named story needs 116 — an extra @supports probe if you hoist.

Anonymous vs named scroll timelines at a glance Two columns. Left column, anonymous timelines: value is scroll() or view() written inline on the consumer; binds to the nearest ancestor scroller or the element's own visibility; one declaration per consumer; rebinds silently when the DOM changes. Right column, named timelines: a dashed-ident declared on the scroller; resolved by ancestor lookup, extendable with timeline-scope; one declaration shared by many consumers; fails to an inactive timeline rather than rebinding. A strip below summarizes: self-contained effect, use anonymous; shared or cross-branch timeline, use named. Anonymous — scroll() / view() • written inline on the consumer • binds to nearest / root / self scroller • one binding per consumer • cannot cross sibling branches • DOM change: rebinds silently • portable, zero coordination Named — <dashed-ident> • declared once on the scroller • resolved by ancestor lookup • many consumers, one timeline • crosses branches via timeline-scope • DOM change: goes inactive, visibly • explicit, greppable contract Self-contained, element-local effect → anonymous. Shared, multi-consumer, or cross-branch timeline → named.

Implementation

Refactoring between the two forms is mechanical once you know which sub-properties each side touches. Both directions below use a reading-progress bar as the worked example — the anonymous version matches the reading progress bar walkthrough.

Refactoring anonymous → named

Start state — consumer binds directly to the root scroller:

.progress {
  transform-origin: 0 50%;
  animation: grow linear both;
  animation-timeline: scroll(root);
}
  1. Declare the name on the scroller you actually mean. scroll(root) means the document scroller, so name it there:

    html {
      scroll-timeline: --page-scroll y;
    }

    Match the axis to what the anonymous function implied: scroll(root) defaulted to the block axis, so block (or y in a fixed horizontal-writing context) preserves behavior.

  2. Swap the function for the ident — keeping the longhand after the animation shorthand, since the shorthand resets animation-timeline:

    .progress {
      transform-origin: 0 50%;
      animation: grow linear both;
      animation-timeline: --page-scroll;
    }
  3. Add timeline-scope only if a consumer is not a descendant of the scroller. For html as scroller it never is — every element descends from html. For an inner pane feeding a sibling toolbar, hoist on the common ancestor; the full wiring is in sharing a scroll timeline across components.

  4. Migrate view() the same way with view-timeline on the subject element (the one whose visibility is tracked). This is the one refactor that changes semantics meaningfully: animation-timeline: view() tracks the consumer itself, while a named view timeline can track a different element — you can now fade a caption as its image crosses the viewport. view-timeline-inset stays a separate longhand; it is not part of the view-timeline shorthand.

  5. Consolidate duplicates. After the swap, any siblings that carried their own identical scroll(root) bindings can reference --page-scroll instead — one greppable name, one place to retarget.

Refactoring named → anonymous

Going the other way is warranted when a component leaves shared-timeline duty and needs portability — e.g., extracting a self-fading card out of an app shell into a design system.

  1. Confirm the element is the only consumer of the name (grep the ident).
  2. Replace the ident with the equivalent function: nearest-ancestor scroller → scroll(), document scroller → scroll(root), tracked own visibility → view(). There is no anonymous equivalent of “that specific scroller over there” — if the binding relied on timeline-scope to reach a non-ancestor, it cannot become anonymous.
  3. Delete the now-unconsumed scroll-timeline / view-timeline declaration and any timeline-scope entry that listed the name. Leaving them costs nothing at runtime but rots into misleading documentation.

The scroll timeline API reference covers the full argument grammar for both functions — scroll(nearest | root | self, block | inline | x | y) and view(<axis> <inset>) — if you need the less common variants.

Verification

The two forms fail differently, and your debugging workflow should exploit that.

Anonymous timelines fail by binding to the wrong thing. scroll(nearest) under a wrapper that unexpectedly gained scrollable overflow rebinds without complaint; the animation still runs, just against the wrong scroller. Verify which source is bound:

const anim = document.querySelector('.progress').getAnimations()[0];
// ScrollTimeline exposes its source element
console.log(anim.timeline.source);  // expect: document.scrollingElement
console.log(anim.timeline.axis);    // expect: "block" or "y"

Named timelines fail by going inactive. A typo’d ident, a missing scope, or duplicate declarers all produce the same symptom — the element sits at its base styles. Check resolution rather than motion:

const anim = document.querySelector('.progress').getAnimations()[0];
console.log(anim.timeline);              // null / inactive ⇒ name never resolved
console.log(anim.timeline?.currentTime); // null ⇒ inactive timeline

Then in the Elements panel: Computed → animation-timeline shows the ident on the consumer, and the scroller must show a matching scroll-timeline-name. If the consumer’s computed value shows auto instead of your ident, a later animation shorthand reset it — the cascade gotcha below.

For both forms, finish with a Performance-panel scroll recording: progress ticks on the compositor track, no per-frame style recalc storms.

Edge cases and gotchas

The animation shorthand resets both forms equally. animation: grow linear both resets animation-timeline to auto whether you meant scroll(root) or --page-scroll. Every rule that sets the shorthand must re-state the timeline longhand after it. This is the single most common breakage in code review.

Neither form inherits — but idents cascade as names, functions do not. animation-timeline itself is not inherited in either form. The difference is upstream: a named timeline’s declaration (scroll-timeline-name) lives on the scroller and is visible to an entire subtree via lookup, so moving a consumer within that subtree preserves the binding. An anonymous function re-evaluates nearest at the consumer’s new position — same CSS, potentially different scroller.

Dashed-idents are not custom properties. --page-scroll cannot be set via var(), cannot be defined with @property, and does not inherit as a value. animation-timeline: var(--tl) where --tl: --page-scroll does work in Chromium as ordinary custom-property substitution — but it defeats greppability and confuses DevTools traces; avoid it.

Specificity conflicts resolve per-property, not per-strategy. If one rule sets animation-timeline: view() and a more specific one sets animation-timeline: --page-scroll, the ident wins wholesale — there is no merging of range or axis from the loser. Keep each element on one strategy; mixed declarations across media queries are where “works on desktop, frozen on mobile” bugs come from.

scroll(self) vs a self-named timeline are not identical in reach. scroll(self) reads the consumer’s own scroll position and only its own; naming the element’s timeline lets other elements read that same scroller. If there is any chance a second consumer appears, name it from the start.

Duplicate names have no anonymous analogue. Two elements declaring --carousel under one timeline-scope null the timeline for everyone — a failure class that simply cannot happen with anonymous functions. Multi-instance components should either stay anonymous or generate per-instance names.

Browser-specific notes

Chrome / Edge (Chromium). Both anonymous functions and named timelines shipped in 115; timeline-scope followed in 116. DevTools support is equal for both forms — getAnimations(), the Animations drawer, and computed styles all report scroll timelines — but only named timelines surface a human-readable ident in the Styles pane, which is a genuine debugging advantage on pages with many scroll-driven effects.

Firefox. Scroll-driven animations are implemented behind a pref (Nightly builds); anonymous and named forms ride the same flag, so there is no strategic reason to prefer one for Firefox compatibility. Guard both with @supports (animation-timeline: scroll()).

Safari. Support is in active development (Technology Preview). The scroll-timeline polyfill handles scroll(), view(), and named timelines; hoisted timeline-scope patterns are its weakest area, which is itself an argument for keeping effects anonymous where the polyfill must carry them.

Feature-detection asymmetry. @supports (animation-timeline: scroll()) passes wherever anonymous timelines work; add the separate @supports (timeline-scope: --check) probe before shipping cross-branch named patterns, per the progressive enhancement guard recipes.


Up: Named Timelines & timeline-scope