timeline-scope for Cross-Element Animations
A view timeline normally drives the element that owns it: a card fades in as the card scrolls into view. Many effects need something different — one element's motion driven by another element's scroll position. A table of contents highlights the link for the section currently on screen. A sticky caption changes as each figure passes. A progress dot in a side rail fills as its chapter is read. These elements are siblings or cousins of the thing being tracked, not descendants, and that is exactly what timeline-scope makes possible. It lifts a named timeline up to a common ancestor so any element beneath it can animate against it. This page builds a CSS-only scroll spy, explains the lookup rules, and covers what still needs script. It belongs to Scroll-Driven Animations in the CSS-Only Micro-Interactions & Animations guide.
How named timelines are found
A named timeline is declared with view-timeline-name (or scroll-timeline-name) on the element being tracked. An element that says animation-timeline: --intro looks for a timeline called --intro among its own ancestors, including itself. If the tracked element is a sibling, it is not an ancestor, so the lookup fails. timeline-scope: --intro on a shared ancestor changes that: the ancestor now "owns" the name, and the timeline declared by any of its descendants is attached to it, visible to every other descendant.
The complete implementation
A two-column layout: a navigation list and a scrolling article. Each section declares a view timeline; the scoped ancestor makes those names visible to the links. Scroll the article in the demo and watch the links.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSS-only scroll spy</title>
<style>
body { margin: 0; font: 15px/1.6 system-ui, sans-serif; }
.layout {
display: grid;
grid-template-columns: 10rem 1fr;
block-size: 100vh;
/* Hoist the section timelines so the nav can use them. */
timeline-scope: --intro, --setup, --usage;
}
.toc { padding: 1rem; border-inline-end: 1px solid #cbd5e1; }
.toc a {
display: block;
padding: 0.25rem 0.5rem;
border-radius: 6px;
color: inherit;
text-decoration: none;
}
.article { overflow-y: auto; padding: 0 1.5rem; }
.article section { min-block-size: 80vh; padding-block: 1rem; }
#intro { view-timeline-name: --intro; }
#setup { view-timeline-name: --setup; }
#usage { view-timeline-name: --usage; }
@supports (animation-timeline: view()) {
.toc a {
animation: spy linear both;
/* Highlight while the section occupies the middle of the scroller. */
animation-range: cover 30% cover 70%;
}
.toc a[href="#intro"] { animation-timeline: --intro; }
.toc a[href="#setup"] { animation-timeline: --setup; }
.toc a[href="#usage"] { animation-timeline: --usage; }
@keyframes spy {
0%, 100% { background: transparent; box-shadow: none; }
1%, 99% { background: #dbeafe; box-shadow: inset 3px 0 0 #2563eb; }
}
}
</style>
</head>
<body>
<div class="layout">
<nav class="toc" aria-label="On this page">
<a href="#intro">Introduction</a>
<a href="#setup">Setup</a>
<a href="#usage">Usage</a>
</nav>
<main class="article">
<section id="intro"><h2>Introduction</h2><p>…</p></section>
<section id="setup"><h2>Setup</h2><p>…</p></section>
<section id="usage"><h2>Usage</h2><p>…</p></section>
</main>
</div>
</body>
</html>
The keyframes use a near-step pattern: transparent at the very ends, highlighted from 1% to 99%. Combined with the cover 30% cover 70% range, the link is highlighted while its section occupies the middle band of the scroller and plain otherwise. animation-fill-mode: both holds the transparent end states outside the range, so a link does not stay highlighted after its section scrolls away.
The key technique: the range decides which link is active
A scroll spy should highlight one link at a time. With view timelines, each link runs independently, so "one at a time" comes from choosing ranges that do not overlap for adjacent sections. The cover 30% cover 70% range is active while the section's journey is between 30% and 70% — roughly while it crosses the middle of the scroller. Sections taller than the scroller make this easy; short sections can overlap, so give each section a min-block-size or narrow the range.
Order of declarations matters
The animation shorthand resets animation-timeline to auto — a document timeline measured in time. In the implementation, the shorthand sits on .toc a and each link's animation-timeline sits in a separate, more specific rule, so the timeline survives. If both were written in the same rule, animation-timeline would have to come after the shorthand; written before it, the shorthand silently overwrites it, and the animation, which has no duration of its own, completes instantly on page load and sits at its end state instead of following the scroll. DevTools shows the computed animation-timeline value, which is the quickest way to confirm the problem. animation-range should likewise come after the shorthand. This ordering trap is the most common reason a scroll-driven animation "does nothing", and it is easy to miss because the declarations look correct in isolation.
Scroll spy for horizontal and nested layouts
Named timelines work on either axis. A horizontally scrolling set of slides can drive a row of dot indicators beneath it by declaring view-timeline: --slide-1 inline on each slide; the inline axis argument makes the timeline measure horizontal progress. Nested scrollers need care: a section's view timeline attaches to its nearest scroll container. If the article sits inside another scrolling panel, the timeline follows the inner scroller, which is usually what you want for a table of contents placed beside that panel. If the whole page scrolls instead, remove the inner overflow so the sections track the document scroller. When in doubt, check which ancestor has a computed overflow other than visible or clip.
Limits: styling, not state
A CSS scroll spy changes appearance only. It cannot set aria-current="location" on the active link, so a screen reader user navigating the table of contents gets no indication of the current section. For many sites that is acceptable, because the highlight is a visual convenience and the headings themselves provide structure. When the current section matters — long documentation with deep navigation, for example — pair the CSS highlight with a small IntersectionObserver that sets aria-current, and let the CSS keep doing the visual work without main-thread scroll handlers.
The other limit is that timeline names must be written out. timeline-scope takes a comma-separated list of the names to hoist, and each link still needs its own animation-timeline rule. For a table of contents generated by a static site generator, emitting these rules alongside the markup is straightforward; for hand-written pages with dozens of sections, it becomes tedious. Inline styles on each link, such as style="animation-timeline: --setup", keep the rules next to the content they refer to.
Variation: a progress dot per chapter
The same wiring drives more than highlights. A rail of dots, one per chapter, can fill as each chapter is read:
.book { timeline-scope: --ch1, --ch2, --ch3; } /* ancestor of rail and chapters */
#ch1 { view-timeline-name: --ch1; }
#ch2 { view-timeline-name: --ch2; }
#ch3 { view-timeline-name: --ch3; }
.rail .dot::after {
content: "";
display: block;
aspect-ratio: 1;
border-radius: 50%;
background: #2563eb;
scale: 0;
animation: fill-dot linear both;
animation-range: contain; /* fill while the chapter covers the screen */
}
.rail .dot:nth-child(1)::after { animation-timeline: --ch1; }
.rail .dot:nth-child(2)::after { animation-timeline: --ch2; }
.rail .dot:nth-child(3)::after { animation-timeline: --ch3; }
@keyframes fill-dot { to { scale: 1; } }
Here each dot's inner fill scales from zero to full across the contain range of its chapter, so a half-read chapter shows a half-filled dot. The chapters must be taller than the scroller for contain to mean "covering the screen"; for short chapters, cover 25% cover 75% behaves more predictably. As always with scroll-linked motion, a reduced-motion preference is best honoured by switching to a static indicator.
Browser support
timeline-scope is supported in Chrome and Edge 116+ and Safari 26+. animation-timeline and animation-range are supported in Chrome and Edge 115+ and Safari 26+. In Firefox, scroll-driven animations are available only as a preview behind a flag, not in stable releases, so the @supports (animation-timeline: view()) wrapper leaves the navigation as a plain list of links there. Nothing breaks; the highlight is simply absent.
FAQ
What does timeline-scope do? It hoists a named scroll or view timeline up to an ancestor, so any descendant of that ancestor can use it. Without it, a named timeline is only visible to the element that declares it and its descendants.
Can a sibling element animate from another element's scroll position?
Yes, with timeline-scope. Declare the timeline name on the common ancestor with timeline-scope, name the timeline on the tracked element, and reference the name with animation-timeline on the sibling.
Why is my named timeline not found?
The animating element must be a descendant of either the element that defines the timeline or an ancestor that lists it in timeline-scope. If it is neither, the name resolves to nothing and the animation has no timeline, so it does not run.
Can this replace IntersectionObserver for a scroll spy?
For the visual highlight, yes, in browsers that support scroll-driven animations. It cannot update aria-current or other attributes, so if assistive technology needs to know the current section, a small script is still required.
Related
- Scroll-Driven Animations — the parent guide.
- View Timelines and animation-range — the ranges used here.
- Scroll Progress Bar Without JavaScript — a page-level scroll timeline.
- Scroll-Driven Animation Fallbacks — static alternatives for other browsers.
Related articles
More pages in the same section.