Which Element Becomes the Scroller: nearest, root and self
An anonymous scroll() timeline has to pick a scroll container, and the keyword you pass — or omit — decides how it picks. Getting this wrong produces an animation that runs, attaches to something real, and follows the wrong box, which is considerably harder to diagnose than one that does not run at all. This guide covers what each keyword resolves to, how to choose between them, and why nearest is the one that changes when someone else edits the layout. It sits under timeline attachment and scroll container mechanics.
When to use this approach
root— when the effect describes position in the document. A reading progress bar, a page-wide parallax background, a header that compresses as the reader leaves the top: all of these mean “how far down the page am I”, and none of them should be diverted by an inner panel that happens to scroll.nearest(the default) — when the animated element genuinely lives inside the scroller that should drive it, and you are confident nothing else scrollable sits between them. This is the right choice for effects scoped to a component, and the wrong choice for anything page-level on a site with modals, drawers or scrollable panels.self— when the element scrolls its own content and the effect is about that. Fading the trailing edge of a horizontally scrollable code block, showing a shadow under a sticky header inside a scrollable panel, or dimming a “more below” affordance are the canonical cases.- A named timeline instead — when the animated element is not a descendant of the scroller at all. No keyword can express that relationship, and reaching for one is the sign you need named timelines.
Implementation
1. Decide what the effect is describing
Write the sentence before the CSS. “The bar shows how far through the article the reader is” resolves to the document. “The card tilts as it moves through the panel” resolves to the panel. “The gradient appears when there is more to scroll” resolves to the element itself. Nearly every attachment bug is a mismatch between the sentence and the keyword.
2. State the keyword explicitly
/* the effect is about the document */
.reading-progress {
animation: fill linear both;
animation-timeline: scroll(root block);
}
/* the effect is scoped to whichever panel contains this card */
.panel .card {
animation: tilt linear both;
animation-timeline: scroll(nearest block);
}
/* the effect is about this element's own overflow */
.code-scroller {
overflow-x: auto;
animation: fade-trailing-edge linear both;
animation-timeline: scroll(self inline);
}
Writing nearest explicitly rather than relying on the default costs one word and documents the intent, which matters because the default is the one that silently changes meaning.
3. Give self something to scroll
scroll(self) produces a usable timeline only when the element is itself a scroll container, which means the same three conditions apply to it: a scrolling overflow value, content that exceeds the box, and a definite size on that axis.
.code-scroller {
overflow-x: auto; /* scrolling overflow on the inline axis */
max-inline-size: 100%; /* definite size, so content can exceed it */
}
Without the size constraint the element widens to fit its content, never overflows, and self yields an empty range.
4. Reach for a name when the relationship is not ancestral
.gallery { scroll-timeline: --gallery inline; }
.toolbar { /* sibling of .gallery, not inside it */ }
.page { timeline-scope: --gallery; }
.toolbar .indicator {
animation: fill linear both;
animation-timeline: --gallery;
}
Verification
Read the computed value first. getComputedStyle(el).animationTimeline tells you whether the declaration survived the cascade, and a value of none ends the investigation immediately — the problem is resolution, not the keyword.
Then identify the actual scroller empirically. Scroll each candidate container in turn and watch the animation. The one that moves it is the one that resolved, and on a page with several nested scrollers this is faster than reasoning about the tree. The Animations panel makes the same test easier to read, because the effect’s playhead visibly tracks whichever container is driving it.
Finally, confirm the range is real. Compare scrollHeight with clientHeight on the resolved scroller. If they are equal, the element is not scrollable at the current viewport size, and the animation will sit at its start value no matter which keyword you used.
Edge cases and gotchas
A modal or drawer becomes the nearest scroller. Overlay components frequently set overflow: auto on a container that wraps page content while open. Any scroll() inside that content silently re-resolves for as long as the overlay is mounted, which produces animations that behave differently depending on application state.
self on a non-scrolling element. This resolves to a timeline with no range rather than failing loudly. The animation attaches, reports a resolved timeline in the computed value, and never advances — the most confusing of the failure modes, because every signal except the visual one looks correct.
root on a page where the document does not scroll. Application shells sometimes give html and body a fixed height and scroll an inner element instead. In that layout root resolves to a scroller with no range, and the fix is a named timeline on the element that actually scrolls.
Writing modes change what block means. scroll(root block) follows the writing mode, so on a vertical-writing-mode page it reads the horizontal axis. That is usually what you want; when it is not, x and y name physical axes and ignore the writing mode.
Browser-specific notes
Keyword resolution is specified precisely and the engines agree on it, so differences here are rare and mostly historical. Two are worth knowing.
Early Chromium builds resolved nearest against elements with overflow: hidden inconsistently in nested cases; current builds treat hidden as establishing a scroll container, matching the specification. If a timeline behaves differently on an old stable build, an intermediate overflow: hidden is the first thing to check.
Firefox shipped the timeline functions later and with the specified behaviour throughout, so it has no legacy quirk here — but it is also the engine most likely to be running an older stable version in a corporate environment, which makes a feature query rather than a version check the right guard.
Safari’s implementation matches the specification for all three keywords. Its distinctive behaviour is in view() inset parsing rather than scroller resolution, which is covered in the polyfill guide.
Frequently Asked Questions
Is omitting the keyword the same as writing nearest?
Yes, functionally — scroll() and scroll(nearest block) resolve identically. The difference is in what the code communicates. A bare scroll() reads as “whatever is closest”, which is exactly the behaviour that changes when someone adds an overflow container three levels up. Writing the keyword out says that the nearest container is what you meant, which turns a later regression into a visible contradiction rather than a silent one.
Can I test which scroller resolved without DevTools?
Yes, and it is often quicker. Temporarily give the animation a keyframe that is impossible to miss — a full-page background colour change, or a large translation — and then scroll each candidate container in turn. Whichever one moves it is the resolved scroller. This costs thirty seconds and settles a question that reasoning about the tree frequently does not.
Does root mean html or body?
It means the document’s scrolling element, which is html in standards mode. The distinction matters when a stylesheet has moved scrolling onto body by giving html a fixed height — in that layout root resolves to a container with no scroll range, and the animation sits at zero. Restoring document scrolling or naming a timeline on the element that actually scrolls are both valid fixes; the second is safer in an application shell that genuinely needs a fixed root.
What resolves when the animated element is inside a shadow tree?
The lookup crosses the shadow boundary and continues up the flattened tree, so a component’s internal element can resolve to a scroller in the host document. This is usually convenient and occasionally surprising: a design-system component with a scroll() animation inside it behaves differently depending on what the consuming page wraps it in, which is another argument for naming the scroller inside components you ship to other teams.
Related
- Why a scroll timeline resolves to none — the causes to check when resolution fails outright
- Scroll timelines on nested scroll containers — addressing a specific container among several
- scroll() vs view() timeline comparison — choosing which function supplies progress
- Anonymous vs named scroll timelines — when a keyword cannot express the relationship