Scroll Timelines on Nested Scroll Containers

Application layouts nest scroll containers routinely: a shell that scrolls, a panel inside it that scrolls, a list inside that. The anonymous keywords can address the innermost container and the document, and nothing in between β€” which means the moment a layout has three levels of scrolling, some of your animations need names. This guide covers how nested attachment resolves, how to reach a container the keywords cannot name, and what happens when two scrollers move at once. It sits under timeline attachment and scroll container mechanics.

When to use this approach

  • A component-scoped effect inside one panel β€” the anonymous scroll() is correct and needs nothing else, provided the panel is genuinely the nearest scroll container to the animated element.
  • An effect driven by a container that is neither innermost nor the document β€” a named timeline is the only option. There is no keyword for β€œtwo levels up”.
  • An indicator outside the container it describes β€” a name plus timeline-scope, because the consumer is not a descendant of the scroller.
  • An effect that should combine two scrollers β€” two animations on the same element, each with its own timeline, animating different properties. A single animation reads exactly one timeline.
Addressing one container among several A page containing a scrollable shell, which contains a scrollable panel, which contains the animated card. A bare scroll() selects the innermost panel. scroll(root) selects the document. Only a named timeline can select the middle container, because no keyword names it. html β€” scroll(root) .shell β€” no keyword names it .panel β€” scroll() .card β€” the animated element What selects what scroll() .panel β€” the innermost scroll(root) html --shell .shell β€” only via a name the middle of a nested stack is unreachable without a named timeline

Implementation

1. Name the container you mean

.shell {
  block-size: 100dvh;
  overflow-y: auto;
  scroll-timeline: --shell block;
}

.panel {
  block-size: 60vh;
  overflow-y: auto;
  scroll-timeline: --panel block;
}

Declaring a name on a container costs nothing until something consumes it, so naming every scroller in a layout up front is a reasonable convention β€” it removes the later refactor where an effect suddenly needs one.

2. Hoist each name only as far as it needs to go

/* the toolbar sits beside .panel, so their common ancestor is .shell */
.shell { timeline-scope: --panel; }

timeline-scope accepts several names, so a layout element that contains multiple scrollers and multiple consumers can hoist them together:

.shell { timeline-scope: --panel, --sidebar; }

3. Consume the name from wherever the element lives

.toolbar .panel-progress {
  animation: fill linear both;
  animation-timeline: --panel;
  transform-origin: left;
}

4. Combine two scrollers with two animations

An element can carry several animations, each reading a different timeline, as long as they do not target the same property:

.badge {
  animation:
    fade   linear both,
    drift  linear both;
  animation-timeline: --panel, --shell;
}

@keyframes fade  { from { opacity: 0; }   to { opacity: 1; } }
@keyframes drift { from { translate: 0 8px; } to { translate: 0 -8px; } }

animation-timeline accepts a list positionally, matching the animation list. If the two keyframe sets both animated opacity, the later one would simply win.

Two scrollers, two progress values The outer shell is 30 percent scrolled while the inner panel is 80 percent scrolled. An animation attached to the shell reads 0.3 and one attached to the panel reads 0.8 at the same instant. Neither value is derived from the other, and an animation can only read one of them. .shell progress 0.30 .panel progress 0.80 an animation reads exactly one of them to combine both, use two animations on one element Two animations on the same element may read different timelines β€” provided they animate different properties.

Verification

Confirm each name resolves independently. Read getComputedStyle(el).animationTimeline on the consumer: a resolved timeline is not the literal string you typed, and none means the lookup failed β€” almost always because timeline-scope is missing or is on an element that does not contain the consumer.

Then scroll each container in isolation. With nested scrollers this is the decisive test: scrolling the shell should move exactly the animations bound to --shell, and scrolling the panel exactly those bound to --panel. An animation that responds to both is reading a timeline you did not intend, usually because a bare scroll() survived somewhere in the stylesheet.

Finally, check both containers have real ranges at the viewport sizes you support. Nested layouts frequently collapse on narrow screens β€” the panel loses its fixed height and stops scrolling β€” which silently disables every animation bound to it.

Hoisting a name out of a nested container The name is declared on the inner panel. The consumer sits in a toolbar outside it. Declaring timeline-scope on the shell β€” the lowest element containing both β€” makes the lookup succeed. Declaring it on the panel itself or on the document are the two ways to get this wrong. .shell β€” timeline-scope: --panel .panel scroll-timeline: --panel block .toolbar animation-timeline: --panel lookup succeeds Two ways to get it wrong scope on .panel β€” the toolbar is outside it, so the name is invisible scope on html β€” works, but every component now shares one namespace

Edge cases and gotchas

The innermost scroller is not the one you meant. A list inside the panel that scrolls independently becomes the nearest container for anything inside it. This is the single most common nested-layout surprise, and the reason a bare scroll() is risky in any layout deeper than two levels.

A name declared on two containers within one scope. If both .panel-a and .panel-b declare --panel and a common ancestor scopes it, the name is ambiguous and resolves to nothing. Give each container a distinct name, or scope each name at an ancestor that contains only one declarer.

Scoping at the document root. It always works, which is why it is tempting, and it converts every timeline name into a global. Two unrelated components that both chose --panel then collide, and the failure appears in whichever one loads second.

Panels that only scroll at some viewport sizes. A container with overflow: auto and content that fits on desktop but overflows on mobile is a scroll container only on mobile. Any nearest resolution against it changes with the viewport, which produces animations that behave differently across breakpoints for no visible reason.

Scroll chaining muddies manual testing. When an inner container reaches its end, further scrolling propagates to its parent. During verification this can make it look as though one gesture drives both timelines; overscroll-behavior: contain on the inner container isolates them while you test.

Browser-specific notes

All three engines resolve nested attachment as specified, and timeline-scope is supported alongside the named-timeline properties rather than separately in current stable builds. The differences that do exist are in scrolling behaviour rather than in timeline resolution, and they show up as differences in when progress changes rather than in which timeline is attached.

Chromium’s scroll chaining and momentum behaviour on nested containers differs slightly from WebKit’s, so a flick that ends exactly at a container boundary can leave the two engines a frame or two apart in progress. This is not observable in normal use and is worth knowing only when comparing recordings across engines.

Safari applies rubber-band overscroll to inner containers as well as the document. Progress is clamped to the valid range, so the animation holds at its endpoint rather than overshooting, but the visual pause while the container springs back can read as the animation stalling.

Firefox’s implementation is the most recent of the three, and its nested behaviour matches the specification without legacy quirks. As always, guard on the feature query rather than on the engine.

Frequently Asked Questions

Should every scroll container in a layout be given a name?

It is a reasonable convention for application shells, where the nesting is stable and several components may eventually need to reference a specific level. Names cost nothing until consumed, and adding one later means touching a layout file that many things depend on. For content sites with a single document scroller, naming is overhead β€” reach for it when a second scroll container appears, not before.

Why does my animation respond to two different containers?

Because two animations are attached to it, or because scroll chaining is propagating one gesture to two scrollers. Check the number of entries in the Animations panel for that element first; if there is only one, set overscroll-behavior: contain on the inner container and repeat the test, which isolates the gesture and makes the true binding obvious.

Can a named timeline be consumed from inside a different nested container?

Yes, provided the hoisting element contains both. The lookup does not care how many scroll containers lie between the declarer and the consumer β€” it only cares that the name is visible on the ancestor chain. An indicator inside a sidebar that scrolls independently can be driven by a name declared on a content panel, as long as timeline-scope sits on an element containing both.

What happens when a nested container is removed and re-added?

The name disappears and reappears with it. Consumers whose timeline resolved through that declaration fall back to no timeline while it is absent, which for a running animation means jumping to its fill state. In component-driven layouts this makes the container that declares the name a component worth keeping mounted, rather than one that unmounts with a route change β€” the same consideration that applies to scroll timelines after a page swap.


Up: Timeline Attachment & Scroll Container Mechanics