Nested Scrollers and Timeline Lookup

Part of Scroll Timeline Scoping & Ranges in Modern View Transitions & Scroll APIs.

The problem

A reading progress bar uses animation-timeline: scroll() and works on the blog. On the documentation site, the same component sits inside a layout wrapper and never moves. On a third page it moves, but only when a code sample inside the article is scrolled sideways. Reveal effects with view() inside a card list work until the list is placed inside a tab panel, after which every card is permanently at its start state.

Nothing is wrong with the animations. They are attached to a different scroll container than the one the user is scrolling.

Root cause analysis: nearest scroll container, not nearest scrolling

scroll() looks up the tree. An anonymous scroll() timeline defaults to scroll(nearest block): the nearest ancestor of the animated element that is a scroll container. scroll(root) uses the document’s root scroller, and scroll(self) uses the element itself.

view() tracks its subject in the nearest scroller. An anonymous view timeline uses the animated element as the subject and measures it against the nearest ancestor scroll container.

“Scroll container” is broader than “scrolls”. Any element with overflow set to hidden, auto or scroll on either axis is a scroll container, even if its content never overflows and it can never actually be scrolled by the user. overflow: hidden on a layout wrapper — a common way to contain floats or clip rounded corners — silently becomes the nearest scroller. A timeline attached to a scroller that never scrolls stays at 0% forever.

overflow: clip is the fix for accidental scrollers. It clips content like hidden but does not create a scroll container, so lookups pass through it to the real scroller above.

Axes matter, but lookup ignores them. An element with overflow-x: auto and overflow-y: visible computes to a scroll container on both axes, so scroll(nearest block) inside it resolves to it even though it only scrolls horizontally. Its block axis never scrolls, and the timeline never progresses. Components placed inside horizontally scrolling regions — tables, code blocks, carousels — are the usual victims.

Named timelines have explicit scope. A named scroll-timeline is visible to the scroller’s descendants, and to other elements only if timeline-scope on a common ancestor lifts the name. Named timelines avoid ambiguity entirely: the animation follows the element that declares the name, as covered in named vs anonymous scroll timelines.

How an anonymous scroll() finds its scrollerThe first ancestor with a non-visible, non-clip overflow wins, even if it never scrolls.How an anonymous scroll() finds its scrollerAnimatedelementParentoverflow visible:skipLayout wrapperoverflow hidden:MATCHMainnever reachedRoot scrollerintended
The first ancestor with a non-visible, non-clip overflow wins, even if it never scrolls.

Step-by-step resolution

Finding and fixing the resolved scrollerTrace upward, fix accidental scrollers, then make intent explicit.Finding and fixing the resolved scroller1In the Elements panel, select the animated element and check each ancestor's computed overflow.The first non-visible, non-clip value is the scroller2If that ancestor should not scroll, change overflow: hidden to overflow: clip.Lookup continues upward3For page-level effects, use scroll(root) instead of relying on nearest.Immune to wrappers4For component scrollers, declare a named scroll-timeline on the scroller.Unambiguous source5Add timeline-scope on a common ancestor if the animated element is not a descendant.Siblings can use the name6Verify progress changes when scrolling the intended container.
Trace upward, fix accidental scrollers, then make intent explicit.

Production code pattern

/* 1. Page progress bar: always the root scroller, whatever wraps it. */
.reading-progress {
  transform-origin: left;
  animation: progress linear both;
  animation-timeline: scroll(root block);
}
@keyframes progress { from { scale: 0 1; } }

/* 2. Layout wrapper that clipped rounded corners with overflow: hidden. */
.layout-card {
  border-radius: 1rem;
  overflow: clip;                 /* was hidden: clip does not create a scroll container */
}

/* 3. A component scroller with an explicit, named timeline. */
.chat-log {
  overflow-y: auto;
  scroll-timeline: --chat block;
}
.chat-app {
  timeline-scope: --chat;         /* the header is a sibling of the log */
}
.chat-app__header-shadow {
  animation: shadow-in linear both;
  animation-timeline: --chat;
  animation-range: 0 48px;
}
@keyframes shadow-in { from { opacity: 0; } }

@media (prefers-reduced-motion: reduce) {
  .reading-progress, .chat-app__header-shadow { animation: none; }
  .chat-app__header-shadow { opacity: 1; }
}

Rendering Impact: composite. Which scroller drives the timeline does not change the cost; the animations use scale and opacity. Changing overflow: hidden to clip also removes an unnecessary scroll container, which can save a little memory and hit-testing work.

overflow: clip differs from hidden in one way that matters beyond timelines: a hidden element can still be scrolled programmatically, for example by scrollIntoView() on a focused descendant, which occasionally shifts content inside rounded cards. clip cannot be scrolled at all, which is usually what a clipping wrapper intended.

Anonymous nearest against explicit rootThe same progress bar inside a wrapper with overflow: hidden.Anonymous nearest against explicit rootanimation-timeline: scroll()Resolves to the wrapperWrapper never scrollsBar stays at 0% foreverSilently brokenanimation-timeline: scroll(root block)Resolves to the document scrollerWrappers are irrelevantBar tracks page scrollWorks in any layout
The same progress bar inside a wrapper with overflow: hidden.

A console check for the resolved source

When the layout is deep, checking ancestors by hand is slow. Script can report the timeline directly: el.getAnimations()[0].timeline.source returns the scroll container a ScrollTimeline resolved to, and for a ViewTimeline it also exposes subject. Logging source and comparing it with the element you expect confirms the lookup in one line. Pair it with getComputedTiming().progress while scrolling, and you can tell the difference between “wrong scroller” (progress never changes) and “wrong range” (progress changes, but not where you expect), which calls for named animation ranges instead.

Verification checklist

Constraints and trade-offs

  • overflow: clip does not support overflow-clip-margin in every browser, and cannot be scrolled programmatically.
  • scroll(root) ignores component scrollers entirely, so reusable components that live in scrolling panels need named timelines.
  • Timeline names must be unique within the scope where they are used.
  • Scroll containers with no overflow still resolve lookups, so adding overflow: auto “just in case” breaks descendants’ timelines.
  • Timeline source inspection APIs may vary across browsers during rollout.

Frequently asked questions

Why does my scroll() animation never progress?

It resolved to the nearest ancestor scroll container, which is probably an element with overflow: hidden that never scrolls. Use overflow: clip on that element or target scroll(root).

Does overflow: hidden create a scroll container?

Yes. Any overflow value other than visible or clip creates a scroll container, even when nothing overflows.

How do I make a timeline follow the page scroll regardless of wrappers?

Use animation-timeline: scroll(root block), which always uses the document’s root scroller.

How do I check which scroller a timeline uses?

Log element.getAnimations()[0].timeline.source in the console and compare it with the intended container.