Timeline Attachment & Scroll Container Mechanics
Before a scroll-driven animation can do anything, the browser has to answer one question: which box supplies the progress? That resolution step is invisible when it succeeds and completely silent when it fails, which makes it the single most common source of “the timeline does nothing” reports. This topic covers how attachment works — the ancestor walk, what qualifies as a scroll container, the axis rules, and the named-timeline machinery that takes over when the structural lookup cannot reach far enough. It sits under Core Animation Fundamentals & Browser Mechanics and assumes the scroll-timeline API basics are familiar.
Guides in this topic
- Which element becomes the scroller: nearest, root and self — how each keyword resolves, and when the answer is not the element you expected.
- Scroll timelines on nested scroll containers — panels inside panels, and how to address a specific one.
- Why a scroll timeline resolves to none — the five causes, in the order worth checking them.
- Overflow, clipping and the scrollport requirements — what
overflow: clipandoverflow: hiddendo differently here. - Horizontal scroll timelines and the inline axis — galleries, carousels and writing-mode-aware axis selection.
Syntax reference
Attachment is expressed through four properties and two functions. The functions produce anonymous timelines resolved structurally; the properties produce named timelines resolved by lookup.
/* Anonymous — the browser finds the scroller for you */
animation-timeline: scroll( <scroller>? <axis>? );
animation-timeline: view( <axis>? <inset>? );
/* <scroller> nearest | root | self default: nearest
<axis> block | inline | x | y default: block
<inset> auto | <length-percentage>{1,2} default: auto */
/* Named — declared on the scroller, consumed elsewhere */
scroll-timeline-name: --gallery;
scroll-timeline-axis: inline;
scroll-timeline: --gallery inline; /* shorthand */
view-timeline-name: --hero;
view-timeline-axis: block;
view-timeline-inset: 10% 20%;
view-timeline: --hero block; /* shorthand */
/* Hoisting a name so elements outside the scroller's subtree can see it */
timeline-scope: --gallery;
/* Consuming a named timeline */
animation-timeline: --gallery;
Two rules govern everything else. An anonymous timeline is resolved from the animated element’s position in the tree, so it can only reach ancestors. A named timeline is resolved by looking up the ancestor chain for a matching declaration or scope, so it can reach anywhere the name has been hoisted to a common ancestor.
Minimal working example
A self-contained page with an inner scroll container, an animation attached to it, and nothing else:
<main class="page">
<div class="panel">
<div class="bar"></div>
<p>Scroll this panel, not the page.</p>
<div style="height: 900px"></div>
</div>
</main>
.panel {
block-size: 320px; /* definite size — condition 3 */
overflow-y: auto; /* scrolling overflow — condition 1 */
}
.bar {
position: sticky;
inset-block-start: 0;
block-size: 4px;
background: currentColor;
transform-origin: left;
animation: fill linear both;
animation-timeline: scroll(); /* resolves to .panel */
}
@keyframes fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Remove block-size from .panel and the whole thing stops working — not because the animation is wrong, but because .panel is no longer a scroll container and scroll() walks past it to the document.
What qualifies as a scroll container
Three conditions must hold simultaneously, on the axis the timeline reads.
A scrolling overflow value. auto, scroll and hidden all establish a scroll container; visible and clip do not. That hidden qualifies surprises people — an element with overflow: hidden has a scroll range that scripts can set even though no scrollbar appears, and a timeline attached to it is perfectly valid. overflow: clip is the value that genuinely removes scrollability.
Content that exceeds the box. A container whose content fits has a scroll range of zero. Progress across a zero-length range is undefined, and the practical result is an animation frozen at its start value. This is overwhelmingly the cause when a reduced test case fails and the real page works — the test case is shorter.
A definite size on that axis. An element that grows to fit its content never overflows it. Flex and grid children are the usual offenders: without min-block-size: 0 a flex item’s default min-height: auto lets it expand past its track, so it stretches instead of scrolling.
animation-range and timeline scoping
Attachment decides which progress; the range decides how much of it the animation uses. The two are independent, and confusing them produces a specific and common bug: an animation attached to the right scroller that appears not to run because its range is empty.
.card {
animation: reveal linear both;
animation-timeline: view();
/* start when a quarter of the card has entered, finish when three
quarters have — not across the whole journey */
animation-range: entry 25% entry 75%;
}
For named timelines, the range names still refer to the timeline’s own geometry. A named scroll timeline has no notion of entry or exit — those are view-timeline concepts — so a range using them against a scroll timeline is invalid and the declaration is dropped. Use percentages, lengths or cover with scroll timelines and the named ranges only with view timelines.
timeline-scope widens where a name can be seen, and nothing else. It does not create a timeline, does not change which element scrolls, and does not affect ranges. Declaring it on an element that is not a common ancestor of both the declaring scroller and the consuming element accomplishes nothing at all.
Compositor-safe properties
Attachment has no bearing on which properties are cheap to animate — that is decided entirely by the property, as covered in the rendering pipeline for scroll animations. What attachment does affect is where the progress value comes from, and there the news is good: the compositor already tracks scroll offsets for every scroll container in order to scroll them, so supplying progress from a nested container costs the same as supplying it from the document.
The one attachment-related cost is layer count. Elements animating transforms on separate scroll containers each need their own compositing layer, and a page with many independently scrolling panels can accumulate more layers than a page with one scroller and the same number of animations.
Common implementation patterns
Pattern 1 — a panel-scoped progress bar. The most common use of a nested scroller. The bar is inside the panel, so the anonymous form resolves correctly with no names at all:
.panel { block-size: 60vh; overflow-y: auto; }
.panel > .progress {
position: sticky;
inset-block-start: 0;
animation: fill linear both;
animation-timeline: scroll();
}
Pattern 2 — a bar outside its scroller. As soon as the indicator moves out of the panel — into a toolbar, a fixed header, a sibling column — the anonymous form cannot reach it and a name becomes mandatory:
.panel { scroll-timeline: --panel block; }
.layout { timeline-scope: --panel; }
.toolbar .progress {
animation: fill linear both;
animation-timeline: --panel;
}
Pattern 3 — explicitly targeting the document. On a page with any inner scrolling region, scroll() is ambiguous by construction. When the intent is the page, say so:
.page-progress {
animation: fill linear both;
animation-timeline: scroll(root block);
}
Pattern 4 — the element as its own scroller. self attaches to the animated element’s own scroll offset, which is useful for effects that respond to an element scrolling its own content — a code block that shades its edges when there is more to scroll:
.code-scroller {
overflow-x: auto;
animation: fade-right-shadow linear both;
animation-timeline: scroll(self inline);
}
Browser support and @supports guard
Attachment behaviour ships together with the timeline functions themselves, so a single guard covers it. There is no separate feature query for nearest versus root, and no engine has shipped one keyword without the others.
.reveal { opacity: 1; } /* baseline: visible, no motion */
@supports (animation-timeline: scroll()) {
.reveal {
opacity: 0;
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 20% entry 70%;
}
}
timeline-scope is the one piece worth testing separately in older stable builds, because it landed after the timeline functions in some engines. @supports (timeline-scope: --x) is a valid query and worth adding around any pattern that depends on hoisting.
Gotchas and failure modes
-
scroll()picked a scroller you forgot about. A wrapper withoverflow: autoadded months ago for a table, a modal body, or a component that scrolls internally will all be found before the document. Scroll each candidate in turn to identify which one drives the animation, then either name the timeline or usescroll(root). -
The scroller cannot scroll. No overflow, no range, no progress. Check
scrollHeightagainstclientHeighton the element you believe is the scroller — if they match, nothing else you change will help. -
The axis is wrong. A horizontally scrolling gallery supplies no block-axis range, so a default
scroll()reads zero. Name the axis explicitly on any scroller that is not vertical. -
A flex or grid child that never overflows.
min-block-size: autolets the child grow instead of scrolling. Setmin-block-size: 0on the item and the container scrolls as intended. -
overflow: clipinstead ofhidden. They look interchangeable and are not:clipremoves scrollability entirely, so an element using it can never be a scroller. -
A name declared and consumed in sibling subtrees. The lookup walks up, never sideways. Without a
timeline-scopeon a common ancestor the name is simply not visible, and the animation falls back to the document timeline — which is why it plays once on load instead of on scroll.
Performance checklist
- Resolve attachment explicitly on any page with more than one scroll container —
scroll(root)or a name, never a barescroll(). - Confirm the intended scroller has a real scroll range at the viewport widths you support, not just at desktop.
- State the axis on every non-vertical scroller rather than relying on the default.
- Declare
timeline-scopeat the lowest common ancestor, never onhtml. - Keep one declarer per scoped name; two in the same scope resolve the name to nothing.
- Check the computed
animation-timelinevalue rather than the authored one when diagnosing.
Frequently Asked Questions
Why does the same CSS work on one page and not another?
Because attachment is resolved against the DOM, and the two pages have different DOMs. A scroll() timeline that finds the document on a simple page finds a wrapper on a page where a layout component added overflow: auto for an unrelated reason. Nothing about the animation changed; the ancestor chain did.
This is the strongest argument for making attachment explicit on any site with shared layout components. scroll(root block) and named timelines both state an intention that survives someone else’s layout refactor, where a bare scroll() silently follows it.
Does view() need a scroll container at all?
Yes, though it never mentions one. A view timeline measures a subject’s progress through a scrollport, and the scrollport belongs to the nearest scroll container — so the same ancestor walk happens, it is just describing which viewport the subject is travelling through rather than which offset supplies progress. An element inside a panel gets a view timeline relative to that panel’s visible area, not the browser window, which is usually what you want and occasionally a surprise.
Can an element be its own scroller and its own animated element?
Yes, that is exactly what scroll(self) expresses, and it is the right tool for effects that respond to an element scrolling its own content: fading the edges of a horizontally scrollable code block, showing a shadow under a scrollable panel’s header, or indicating that a long list has more below. The alternative — a scroll handler measuring the element and toggling classes — is the pattern these timelines were designed to replace, and this is one of the cases where the replacement is exact rather than approximate.
What happens when the scroller changes size?
The range is recomputed and progress is re-derived from the current offset. In practice this means an animation stays consistent when a container is resized, content is added, or the viewport rotates — the browser is not caching a range measured at load time. It also means a container that starts un-scrollable and becomes scrollable when content arrives will start driving its timeline at that moment, with no re-declaration needed.
The one case worth testing deliberately is content that arrives while the reader is partway down. The offset is preserved but now represents a different fraction of a larger range, so the animation appears to jump backwards — the same effect described for progress indicators, and the same fix: reserve space for late-arriving content.
Is there a cost to declaring many named timelines?
Very little. A name is a lookup key, and the timelines themselves are backed by scroll offsets the compositor already tracks. Declaring a name on twenty panels costs essentially nothing until something consumes it, and even then the cost is in the animations rather than the timelines.
What does cost something is timeline-scope declared too high. Hoisting every name to the root turns each lookup into a search of a larger namespace and, more importantly, makes collisions between unrelated components possible. Scope at the lowest common ancestor and both problems disappear.
Reading attachment from DevTools
Attachment is not directly displayed anywhere, but it is reliably inferable from three readings.
The computed value of animation-timeline on the animated element tells you whether resolution succeeded. A value of none means the lookup failed; anything else means it found something. What it does not tell you is which element it found, which is the next question.
The Animations panel is the second reading. A scroll-driven animation appears there without the duration a time-based animation carries, and scrubbing the scroller moves its playhead. If the playhead moves when you scroll a container you did not expect, that container is the scroller — this is the fastest way to identify a mis-resolved nearest.
The third reading is direct measurement. In the console, getComputedStyle(el).animationTimeline confirms the declaration, and comparing scrollHeight with clientHeight on each candidate ancestor identifies which of them can scroll at all. On a page with several nested containers this narrows the candidates in seconds, where reading the stylesheet does not narrow them at all.
Related
- Understanding the CSS Scroll-Timeline API — the timeline functions themselves, and what each one measures
- Named Timelines & timeline-scope — the naming and hoisting machinery in depth
- The Rendering Pipeline for Scroll Animations — what the progress value costs once it reaches a property
- Browser Support & Progressive Enhancement — guard patterns for the whole timeline surface