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.
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.
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.
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.
Related
- Which element becomes the scroller — how resolution picks a container when it succeeds
- Overflow, clipping and scrollport requirements — the overflow values that do and do not establish a scroller
- Scroll timelines on nested scroll containers — naming a container the keywords cannot reach
- Named Timelines & timeline-scope — scope rules and collision behaviour