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.
One DOM, three answers The same nested structure resolved three ways. nearest selects the closest scrollable ancestor, which is the inner panel. root selects the document scrolling element regardless of what lies between. self selects the animated element itself, which only works when that element scrolls its own content. scroll(nearest) html .panel selected .card scroll(root) html — selected .panel .card scroll(self) html .panel .card — selected self only produces a timeline when the animated element itself scrolls; otherwise its range is zero.

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;
}
Choosing a scroller keyword Three rows. Use root when the effect is about position in the document. Use nearest when the animated element lives inside the scroller that should drive it and no other scroll container sits between them. Use self when the element scrolls its own content. A fourth row notes that a named timeline covers everything else. scroll(root block) the effect is about position in the document reading bars, page-wide parallax, header state scroll() the element lives inside the driving scroller and no other scroll container sits between them scroll(self) the element scrolls its own content edge shadows, "more below" affordances anything else — the element is outside the scroller — needs a named timeline

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.

When nearest changes underneath you Two versions of a layout. Before a refactor the card sits directly inside the page, so nearest resolves to the document. After a wrapper with overflow auto is introduced for an unrelated reason, nearest resolves to the wrapper instead, and the animation follows a scroller that barely moves. Before html — resolved .card animation follows the page After a layout change html .shell — overflow: auto .card animation now follows .shell Nothing in the animation's own stylesheet changed. This is why shared layout components and bare scroll() are a poor combination — state the scroller and the refactor becomes harmless.

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.


Up: Timeline Attachment & Scroll Container Mechanics