Anonymous vs Named Scroll Timelines: When Each Wins

animation-timeline accepts two fundamentally different kinds of value: anonymous timeline functionsscroll() 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:

The DOM relationship decides If the animated element is a descendant of the scroller, an anonymous scroll() timeline resolves the scroller by walking up and no name is needed. If the animated element sits outside the scroller — a fixed progress bar, a sibling panel — the anonymous form cannot find it and a named timeline is the only option. scroll() — anonymous .scroller .animated (inside) scroll-timeline-name — named .scroller --t .animated (outside) ask one question: is the animated element a descendant of the scroller? Anonymous is not "the simple version" — it is the version that only works for descendants. A fixed-position progress bar is never a descendant of the scroller in the containing-block sense.
  • 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.

Proving the timeline resolved Three signals. The computed animation-timeline shows the resolved value rather than the authored one. The Animations panel lists the effect with a scroll timeline rather than a time-based one. And scrolling the intended container — not the page — moves the animation. 1. Computed value a resolved timeline is not the string you typed — an unresolved one reads as none 2. Animations panel a scroll-driven effect has no duration in the usual sense — a duration means it fell back 3. Scroll the right box if page scroll moves it but the inner container does not, scroll() picked the wrong scroller

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