CSS Scroll Spy Indicator
A reading progress bar that tracks scroll position using CSS scroll-driven animations.
A scroll spy reports how far through a scroll container the reader has got. Scroll-driven animations make it a stylesheet problem rather than a scripting one: animation-timeline swaps the clock an animation normally runs against for a scroll position, so a bar's width becomes a function of distance scrolled and the browser evaluates it without any code running on the main thread.
The version above uses a named timeline rather than the simpler scroll() function, and that is not decoration. The bar sits in a header that is a sibling of the scrolling area, not inside it, so scroll(nearest) looks past the article and finds the page instead. Naming the timeline is what connects two elements that are not ancestor and descendant.
Scroll the article
The Future of CSS
CSS has evolved dramatically over the past decade. What began as a simple styling language now supports animations, logical properties, and even scroll-driven effects.
Scroll-driven animations are one of the most exciting additions to the platform. They let you tie animation progress directly to a scroll position, entirely without JavaScript.
How It Works
The animation-timeline property accepts a scroll() function that references a scroll container. The browser maps the scroll range to the animation's progress.
A simple @keyframes that goes from width: 0% to width: 100% becomes a reading progress bar with a single CSS property.
Browser Support
Scroll-driven animations are supported in all modern evergreen browsers. No polyfill, no fallback JavaScript needed for the vast majority of users.
HTML
<div class="scroll-area">
<div class="scroll-header">
<span class="scroll-label">Reading Progress</span>
<div class="bar-track"><div class="bar-local"></div></div>
</div>
<div class="scroll-content">
<h3>The Future of CSS</h3>
<p>CSS has evolved dramatically over the past decade. What began as a simple styling language now supports animations, logical properties, and even scroll-driven effects.</p>
<p>Scroll-driven animations are one of the most exciting additions to the platform. They let you tie animation progress directly to a scroll position, entirely without JavaScript.</p>
<h3>How It Works</h3>
<p>The <code>animation-timeline</code> property accepts a <code>scroll()</code> function that references a scroll container. The browser maps the scroll range to the animation's progress.</p>
<p>A simple <code>@keyframes</code> that goes from <code>width: 0%</code> to <code>width: 100%</code> becomes a reading progress bar with a single CSS property.</p>
<h3>Browser Support</h3>
<p>Scroll-driven animations are supported in all modern evergreen browsers. No polyfill, no fallback JavaScript needed for the vast majority of users.</p>
</div>
</div>
CSS
/* The bar is a sibling of the scroller, not a descendant, so
scroll(nearest) would look past it and find the page instead.
A named timeline published with timeline-scope fixes that:
the scroller names it, the ancestor scopes it, the bar reads it. */
.scroll-area {
timeline-scope: --spy;
width: 100%;
border: 1px solid #2a2a2d;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.scroll-content {
scroll-timeline-name: --spy;
padding: 1.25rem 1.25rem 2rem;
height: 280px;
overflow-y: auto;
}
.bar-local {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #b8ff57, #57d9a3);
border-radius: 999px;
animation: reading-progress linear;
animation-timeline: --spy;
animation-fill-mode: both;
}
@keyframes reading-progress {
from { width: 0%; }
to { width: 100%; }
}
.scroll-header {
position: sticky;
top: 0;
background: #141415;
border-bottom: 1px solid #2a2a2d;
padding: .6rem 1rem .5rem;
display: flex;
flex-direction: column;
gap: .45rem;
z-index: 2;
}
.scroll-label {
font-size: .7rem;
font-family: "DM Mono", "Fira Code", Consolas, monospace;
color: #88888f;
text-transform: uppercase;
letter-spacing: .08em;
}
.bar-track {
height: 6px;
background: #1c1c1e;
border-radius: 999px;
overflow: hidden;
}
.scroll-content p {
margin: 0 0 .85rem;
font-size: .875rem;
color: #88888f;
line-height: 1.7;
}
.scroll-content code {
font-family: "DM Mono", "Fira Code", Consolas, monospace;
font-size: .8em;
color: #b8ff57;
}
Other ways to build it
The simple case, with no name involved
When the indicator lives inside the element being scrolled, none of the naming is needed. The bar here is the first child of the scroll box, pinned with position: sticky, so scroll() walks up one level, finds that box, and stops. This is the version worth reaching for first. The named timeline in the demo at the top of the page exists only because its bar sits in a header that is a sibling of the article rather than a descendant.
HTML
<div class="inbar-frame">
<div class="inbar-bar"></div>
<div class="inbar-body"> ...article... </div>
</div>
CSS
.inbar-frame {
height: 280px;
overflow-y: auto;
}
/* first child of the scroller, pinned to its top edge */
.inbar-bar {
position: sticky;
top: 0;
z-index: 2;
height: 3px;
background: #b8ff57;
transform: scaleX(0);
transform-origin: left center;
}
@supports (animation-timeline: scroll()) {
.inbar-bar {
animation: inbar-fill linear both;
/* one step up the tree finds .inbar-frame */
animation-timeline: scroll();
}
}
@keyframes inbar-fill {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
A rail that highlights the current section
A bar says how far. A spy says where. Each entry in the rail runs the same one-line animation on the same named timeline, and what separates them is animation-range: the first covers the opening quarter of the scroll, the second the next, and so on. With no fill mode set, an entry is styled only while the scroll is inside its own range, so exactly one is lit at a time. The last entry is the exception: it also carries forwards, because at the very end of a range an unfilled animation has already stopped applying and the rail would otherwise go dark at the bottom. The active state changes the background as well as the text color, so it does not depend on hue alone.
Intro
Scroll this column. The rail on the left lights the entry whose range the scroll is currently inside.
Nothing observes the sections. Each rail entry simply owns a quarter of the timeline.
Install
The ranges are percentages of the scroll length, so they stretch and shrink with the content.
Pixel ranges would be wrong here, because the sections are not a fixed distance apart.
Usage
Because no fill mode is set, an entry applies its styles only inside its own range.
Add both and every entry would stay lit once its range had been passed.
Percentages also mean the ranges stay correct when the content grows, which pixel values would not.
API
The last entry is the exception: it carries forwards as well, because an unfilled animation has stopped applying by the time the scroll reaches the very end of its range.
Without that one word the rail goes dark the moment the reader hits the bottom, which looks like a fault rather than a boundary condition.
Four entries is comfortable. Past about six the ranges get too narrow to read as anything but a flicker.
HTML
<div class="spy-wrap">
<nav class="spy-rail" aria-hidden="true">
<span class="spy-item spy-i1">Intro</span>
<span class="spy-item spy-i2">Install</span>
<span class="spy-item spy-i3">Usage</span>
<span class="spy-item spy-i4">API</span>
</nav>
<div class="spy-doc">
<h4>Intro</h4>
<p>Scroll this column. The rail on the left lights the entry whose range the scroll is currently inside.</p>
<p>Nothing observes the sections. Each rail entry simply owns a quarter of the timeline.</p>
<h4>Install</h4>
<p>The ranges are percentages of the scroll length, so they stretch and shrink with the content.</p>
<p>Pixel ranges would be wrong here, because the sections are not a fixed distance apart.</p>
<h4>Usage</h4>
<p>Because no fill mode is set, an entry applies its styles only inside its own range.</p>
<p>Add <code>both</code> and every entry would stay lit once its range had been passed.</p>
<p>Percentages also mean the ranges stay correct when the content grows, which pixel values would not.</p>
<h4>API</h4>
<p>The last entry is the exception: it carries <code>forwards</code> as well, because an unfilled animation has stopped applying by the time the scroll reaches the very end of its range.</p>
<p>Without that one word the rail goes dark the moment the reader hits the bottom, which looks like a fault rather than a boundary condition.</p>
<p>Four entries is comfortable. Past about six the ranges get too narrow to read as anything but a flicker.</p>
</div>
</div>
CSS
/* the rail is a sibling of the scroller, so the
timeline has to be named and scoped */
.spy-wrap {
timeline-scope: --spy-rail;
display: grid;
grid-template-columns: 110px 1fr;
}
.spy-doc {
scroll-timeline-name: --spy-rail;
height: 280px;
overflow-y: auto;
}
@supports (animation-timeline: scroll()) {
.spy-item {
/* no fill mode: the styles apply only while the
scroll is inside this item's own range */
animation: spy-on linear;
animation-timeline: --spy-rail;
}
.spy-i1 { animation-range: 0% 25%; }
.spy-i2 { animation-range: 25% 50%; }
.spy-i3 { animation-range: 50% 75%; }
/* at exactly the end of a range an unfilled animation
has already stopped applying, so the last entry
needs forwards or the rail goes dark at the bottom */
.spy-i4 {
animation-range: 75% 100%;
animation-fill-mode: forwards;
}
}
/* both stops the same, so the state is flat across the range */
@keyframes spy-on {
from, to {
background: #b8ff57;
color: #0c0c0d;
}
}
How it works
animation-timeline: scroll() links an animation's progress to a scroll container. scroll(root block) tracks the page scroll and scroll(nearest block) tracks the nearest scrollable ancestor. The @keyframes go from width: 0 to width: 100%. The browser drives the animation position from the scroll offset, so no requestAnimationFrame is involved.
scroll(nearest) walks up from the animated element looking for the first ancestor that scrolls. That works when the indicator lives inside the thing it measures. It fails silently when it does not, and here it does not: the progress bar is inside a sticky header that is a sibling of the scrolling article, so the search walks straight past the article and lands on the page. The bar then reports how far down this page you have read, which looks plausible enough to ship and is measuring the wrong thing entirely.
A named timeline fixes it in two declarations. The scrolling element publishes its own timeline with scroll-timeline-name: --spy. By default that name is only visible to that element's descendants, so a common ancestor adds timeline-scope: --spy to raise it into scope for everything inside that ancestor, the bar included. The bar then asks for it by name with animation-timeline: --spy and no ancestor walking is involved.
timeline-scope is the piece that is easy to forget, because without it the CSS is still valid. The bar's animation-timeline: --spy resolves to nothing, the animation is inactive, and the bar sits at its from state forever. There is no console warning. If a named timeline appears to do nothing, the missing scope declaration is the first thing to check.
animation-fill-mode: both matters as much here as the timeline does. Without it the bar reverts to its unanimated width once the scroll passes the end of the range, so a reader at the bottom of the article sees the bar collapse to nothing. With it, the from state holds before the range and the to state holds after.
The keyframes animate width, which forces layout on every frame. On a two pixel bar inside a small header that is unmeasurable, but transform: scaleX() with transform-origin: left composites instead, and it is the better default for anything larger. The variant on shrinking header uses the transform version, alongside a header shrinking over a different range on the same timeline.
CSS properties used
animation-timeline- Replaces the document clock with a scroll position. Takes
scroll(),view(), or the name of a timeline published elsewhere. scroll-timeline-name- Published on the scrolling element. Gives its scroll progress a name that other rules can refer to.
timeline-scope- On a common ancestor. Raises a timeline name out of the scroller's own subtree so elements elsewhere can use it.
animation-range- Which slice of the scroll the animation maps onto. Percentages of the scroll length suit an indicator; pixel values suit a fixed distance.
animation-fill-modebothholds the start state before the range and the end state after it, which is what stops the bar snapping back at the end.positionstickyon the header, so the bar stays visible while the article underneath it scrolls.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
position: sticky | 91 | 59 | 7.1 | 91 |
animation | 4 | 5 | 5.1 | 12 |
custom properties | 49 | 31 | 10 | 16 |
@supports | 28 | 22 | 9 | 12 |
prefers-reduced-motion | 74 | 63 | 10.1 | 79 |
Scroll-driven animations have no Can I Use entry, so animation-timeline, scroll-timeline-name and timeline-scope carry no version numbers above and none should be quoted from memory. Support differs by engine and is still changing, so check the current state before relying on it. The failure mode is the important part: a browser that does not understand animation-timeline ignores that declaration and runs the keyframes on the ordinary clock, so a progress bar animates from empty to full once on load and then stays full. Wrap scroll-driven rules in @supports (animation-timeline: scroll()) so that cannot happen, and let the indicator simply not appear where the feature is missing.
Accessibility notes
A progress indicator is decoration for anyone who cannot see it, so mark it aria-hidden="true". It reports something the reader already knows from their own scroll position, and announcing a constantly changing value is worse than announcing nothing. If the progress genuinely needs to be exposed, that is a <progress> element with a real value, and keeping the value current takes script.
Under prefers-reduced-motion: reduce the honest answer is usually to hide the indicator rather than to freeze it. A bar stuck at zero width reads as broken, while an absent bar reads as a design that does not have one. A thin bar that only moves in response to the reader's own scrolling is not the kind of motion that most commonly causes trouble, so this is a judgement call rather than a rule.
Do not use color alone for a section-based spy. The variant below changes both the background and the text color of the active item, so the current section is distinguishable without depending on hue. A shape or a weight change works equally well.
The sticky header carrying the bar takes permanent room from the scroll area, and anything linked to from inside the article lands underneath it. scroll-margin-top on the headings, set to the header height, keeps the landing point clear.
What you can build with it
- Long form articles. A thin bar at the top of the reading area, which is the case this technique is most often used for.
- Documentation with a section rail. The variant below, where each entry in the rail highlights over its own slice of the scroll.
- Terms and policy pages. A visible sense of how much is left, on a page where the length is the main thing the reader wants to know.
- Step by step guides. Progress through a numbered sequence, paired with sticky section headers so the current step is named as well as measured.
- Scrolling dashboards. A position indicator on a tall panel inside an application, where the panel scrolls independently of the page and
scroll(nearest)would find the wrong container.
Mistakes worth avoiding
- Reaching for
scroll(nearest)when the indicator is not inside the scroller. The search walks past the element you meant and finds the page, and the bar tracks the wrong scroll while looking entirely believable. - Publishing a timeline name without
timeline-scopeon a shared ancestor. The name is not in scope,animation-timelineresolves to nothing, the animation never runs, and no warning is produced. - Leaving out
animation-fill-mode: both. The bar reverts to its unanimated state past the end of the range, so it empties out just as the reader reaches the bottom. - Shipping scroll-driven rules with no
@supportsguard. Where the feature is missing the keyframes run on the ordinary clock, so the bar fills once on load and then reports full for the rest of the visit. - Animating
widthon anything substantial. It forces layout every frame.transform: scaleX()with a left transform origin composites and looks identical.
Frequently asked questions
How do I build a scroll progress bar with CSS only?
animation-timeline: scroll() and animation-fill-mode: both. The browser maps the animation onto the scroll position of the nearest scrolling ancestor. If the bar is not inside that scroller, use a named timeline instead.When do I need a named scroll timeline?
scroll(nearest) only walks up the tree, so a bar in a sibling header cannot reach the article. Name the timeline on the scroller, scope it on a shared ancestor, and refer to it by name.What does timeline-scope actually do?
scroll-timeline-name is visible only inside that element's own subtree. timeline-scope on an ancestor raises the name so everything inside that ancestor can see it. Without it the name does not resolve and the animation silently never runs.Why is my scroll-driven animation playing on page load?
animation-timeline, so it ignored that declaration and ran the keyframes as an ordinary time-based animation. Wrap the scroll-driven rules in @supports (animation-timeline: scroll()).