Why a Scroll Timeline Resolves to None

A scroll-driven animation that fails to attach does not throw, does not warn, and does not stop animating. It falls back to the document timeline and plays once on load, which is why the usual bug report is “the animation runs but ignores scrolling” rather than “the timeline is broken”. This guide lists the five causes in the order worth checking them and gives the one reading that eliminates half of them immediately. It sits under timeline attachment and scroll container mechanics.

When to use this approach

Reach for this checklist when any of the following is true:

  • The animation plays once when the page loads and then never moves again.
  • The animation works on one page or breakpoint and not another, with no change to its own styles.
  • A named timeline works in one component and resolves to nothing when the same component is used elsewhere.
  • The animation was working and stopped after an unrelated layout change.

If instead the animation tracks scroll but covers the wrong stretch of it, attachment succeeded and the problem is animation-range — a different investigation entirely, covered in the scroll-timeline API guide.

Five causes, in checking order The causes ranked: the declaration lost the cascade, the resolved scroller has no scroll range, the axis has no range, a named timeline was never hoisted into scope, and two elements declared the same name. Each row states the single reading that confirms or eliminates it. work down the list — each check eliminates one cause in seconds 1. The declaration never applied check: computed animation-timeline shows auto rather than your value 2. The scroller has no scroll range check: scrollHeight === clientHeight 3. The axis has no range check: is the scroller horizontal while the timeline reads block? 4. The name is out of scope check: timeline-scope on a common ancestor of both ends 5. Two elements in one scope declare the same name — the name resolves to nothing

Implementation

1. Read the computed value before anything else

const el = document.querySelector('.reveal');
getComputedStyle(el).animationTimeline;
// "auto"  → the declaration never applied  (cascade problem)
// "none"  → it applied and the lookup failed (attachment problem)
// other   → attachment succeeded; look at the range instead

This single reading splits five causes into two groups and takes a few seconds. Skipping it is the reason attachment bugs take an afternoon rather than a minute.

One reading that splits the causes in two Reading the computed animation-timeline gives one of three answers. The value auto means the declaration never applied and the problem is in the cascade. The value none means the declaration applied but the lookup failed. Anything else means attachment succeeded and the problem is the range or the keyframes. getComputedStyle(el).animationTimeline "auto" the declaration lost the cascade look at specificity and layers "none" applied, but the lookup failed causes 2 to 5 anything else attachment worked look at the range next Take this reading first. It removes at least half the possible causes before any other investigation starts.

2. If the value is auto, find what beat the declaration

The declaration lost the cascade. The usual culprits are an inline style written by a framework, a rule in a later cascade layer, an unlayered rule beating a layered one, or a prefers-reduced-motion block doing exactly what it was written to do. The Styles panel shows the winning declaration with the loser struck through.

/* a reduced-motion reset is a correct, and commonly forgotten, cause */
@media (prefers-reduced-motion: reduce) {
  .reveal { animation-timeline: auto; }
}

3. If the value is none, confirm the scroller can scroll

const s = document.querySelector('.panel');
s.scrollHeight > s.clientHeight;   // false → no scroll range on the block axis
s.scrollWidth  > s.clientWidth;    // check the inline axis for horizontal scrollers

A container whose content fits has no range, so progress is undefined and the timeline is unusable. This is the most common cause on a reduced test case, and the reason a bug frequently disappears when you try to reproduce it in isolation.

4. Check the axis matches the scroller

/* a horizontally scrolling gallery has no block-axis range */
.gallery { overflow-x: auto; }
.gallery .item {
  animation: pan linear both;
  animation-timeline: scroll(nearest inline);   /* not the default block */
}

5. For a named timeline, verify the scope

The lookup walks up from the consumer. If the name is declared in a sibling subtree and no common ancestor carries timeline-scope, the name is simply not visible.

.layout { timeline-scope: --panel; }     /* contains BOTH ends */
.panel  { scroll-timeline: --panel block; }
.toolbar .bar { animation-timeline: --panel; }

6. Check for a duplicate declaration

Two elements declaring the same name inside one scope make the name ambiguous, and the specification resolves ambiguity to nothing rather than picking a winner.

document.querySelectorAll('*').length;   // not this — inspect your own components
// look for a class that declares scroll-timeline-name and matches more than one element

Verification

Once a fix is in, confirm all three conditions at once rather than assuming. Read the computed value again — it should now name a timeline. Scroll the container you intended and watch the animation move with it. Then scroll every other container on the page and confirm the animation does not respond, which catches the case where a fix attached the timeline to a different scroller than intended.

Re-run the check at your narrowest supported viewport. Layouts that collapse on mobile frequently remove the fixed height that made a panel scrollable, which un-attaches every timeline bound to it. An animation that resolves at 1440 pixels and resolves to nothing at 375 is a real bug that desktop testing never surfaces.

The symptom of an unresolved timeline A correctly attached animation advances with scroll and holds at its endpoints. An animation whose timeline resolved to none falls back to the document timeline: it plays once, on load, over its declared duration, and then holds — which looks like an animation that ignores scrolling entirely. Attached progress tracks the scroller, scrubs both ways Unresolved plays once on load holds at its fill state, ignores every scroll "It animates once when the page loads instead of on scroll" is the characteristic report. It is not a keyframe problem or a range problem — the animation simply found no timeline and used the default one. Nothing is logged. The animation is valid; it is attached to the document clock rather than to a scroller.

Edge cases and gotchas

A shorthand that resets the name. Writing animation: reveal 1s linear both after animation-timeline does not clear the timeline — but writing the scroll-timeline shorthand with a missing axis does reset scroll-timeline-axis to its initial value. Shorthands reset every longhand they cover, including the ones you did not mention.

A name that collides with an unrelated component. Two design-system components that both chose --scroller will collide the moment a page uses both under a common timeline-scope. Prefixing names by component removes the class of problem entirely.

A scroller inside a display: contents element. The element generates no box, so it cannot be a scroll container, and any overflow set on it has no effect. This produces a scroller that looks correct in the stylesheet and does not exist in layout.

overflow: clip where hidden was intended. hidden establishes a scroll container with a programmatically scrollable range; clip does not. A timeline attached to a clip element resolves to nothing.

Content that arrives after first paint. A container that is not scrollable at load and becomes scrollable when images or data arrive will start driving its timeline at that moment. Nothing needs re-declaring, but a verification run that only checks the initial state will report a failure that no longer exists.

Browser-specific notes

The failure is silent in all three engines by design — an unresolved timeline is a valid state, not an error — so none of them logs anything. What differs slightly is the tooling around diagnosis.

Chromium’s Animations drawer lists scroll-driven effects distinctly from time-based ones, so an animation that has fallen back to the document timeline is visibly different from one that is scroll-attached. This is the fastest way to confirm cause 2 through 5 without reading computed values.

Firefox’s animation inspector shows the effect and its timing but does not currently distinguish the timeline type as clearly, so the computed-value reading is the more reliable check there.

Safari has no animation inspector, which makes the console reading the primary tool. getComputedStyle(el).animationTimeline works identically across all three, which is why this guide leads with it rather than with a panel.

Frequently Asked Questions

Why does the animation still run if the timeline failed?

Because an animation without a resolved timeline is attached to the document timeline, which is the default. That timeline advances with wall-clock time, so the animation plays through its duration once and then holds at its fill state. Nothing about this is an error condition, which is why there is no warning — the specification defines it as the fallback rather than as a failure.

Can I detect an unresolved timeline in a test?

Yes, and it is worth doing for any effect you rely on. document.getAnimations() returns each running animation, and each has a timeline property; a scroll-driven effect that resolved correctly has a ScrollTimeline or ViewTimeline there, while a fallen-back one has the document timeline. Asserting the timeline type in a browser test catches this class of regression at build time rather than in a bug report.

Does an unresolved timeline cost anything?

Very little in performance terms — it is an ordinary time-based animation, and if the animated property is compositor-safe it still runs on the compositor. The cost is behavioural: the effect fires once at load, which for a reveal means everything appears immediately, and for a progress indicator means it fills to completion regardless of where the reader is.

Is animation-timeline: none ever a deliberate value?

Yes. Setting it explicitly detaches an animation from any timeline, which stops it from running at all rather than falling back to the document timeline. That is occasionally useful as a targeted disable, but for a reduced-motion override auto is the better choice, because it restores the default behaviour rather than leaving the animation in a suspended state.


Up: Timeline Attachment & Scroll Container Mechanics