CSS Scroll Spy Indicator
A reading progress bar that tracks scroll position using CSS scroll-driven animations with no JavaScript, plus a simple variant and a section rail.
- Level
- Advanced
- Published
- Updated
- Reading time
- 7 min
Try it
3 live demos
Check the frame and tires
Start with a visual inspection of the frame. Look for cracks, rust spots, or paint chips that expose bare metal. Check the welds where tubes meet. Run your hand along each seam. Small surface rust can be sanded and touched up with paint. Deep corrosion or cracks mean a trip to a bike shop. Frame damage is not something you fix at home.
Move to the tires next. Spin each wheel and watch for wobbles or hops. A wheel that moves side to side needs truing. Check the tire sidewalls for dry rot, which shows up as small cracks in the rubber. Press your thumb into the tread to feel for soft spots or cuts. Inflate both tires to the pressure range printed on the sidewall. Under-inflated tires make pedaling harder and wear out faster.
While the wheels spin, listen for grinding or clicking. Those sounds usually mean the bearings need attention. A bearing service takes special tools, so most riders bring that work to a shop. Check that the quick-release levers close firmly, with the lever pointing back along the fork or frame. An open lever is an easy way to lose a wheel.
Clean and lubricate the drivetrain
Wipe down the chain with a rag. Shift through every gear to expose the full length. A chain that looks black or gritty needs degreaser. Spray or brush the degreaser on, let it sit for a minute, then scrub with an old toothbrush. Rinse with water and dry the chain completely.
Apply chain lube one drop at a time on the top run of the chain, turning the pedals backward to work it in. Wipe off the excess with a clean rag. Too much lube attracts dirt. The chain only needs oil between the rollers, not coating the outside. A properly lubed chain runs quietly and shifts smoothly.
Check the derailleur cables while you are at it. Frayed cables snap when you shift under load. Shift into the smallest cog on the back wheel and inspect where the cable enters the derailleur. Look for broken strands sticking out from the cable housing. Replace any cable with visible damage.
Test the brakes and adjust the seat
Squeeze each brake lever hard and hold it while you try to push the bike forward. The wheel should lock completely. Inspect the pads for grooves or uneven wear. Pads worn down to the indicator line need replacing before your first ride.
Finish with the saddle height. Stand next to the bike and raise the seat until the top is level with your hip bone. Sit on the bike and put your heel on the pedal at its lowest point. Your leg should be straight. A seat that is too low makes your knees hurt on long rides. Once the height feels right, tighten the seat post clamp and give the saddle a twist to confirm it will not move.
Scroll the article
Other ways to build it
The simple case, with no name involved
See the codeIntro
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 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.
A rail that highlights the current section
See the codeBuild it
6 snippets to copy
CSS Scroll Spy Indicator
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>Check the frame and tires</h3>
<p>Start with a visual inspection of the frame. Look for cracks, rust spots, or paint chips that expose bare metal. Check the welds where tubes meet. Run your hand along each seam. Small surface rust can be sanded and touched up with paint. Deep corrosion or cracks mean a trip to a bike shop. Frame damage is not something you fix at home.</p>
<p>Move to the tires next. Spin each wheel and watch for wobbles or hops. A wheel that moves side to side needs truing. Check the tire sidewalls for dry rot, which shows up as small cracks in the rubber. Press your thumb into the tread to feel for soft spots or cuts. Inflate both tires to the pressure range printed on the sidewall. Under-inflated tires make pedaling harder and wear out faster.</p>
<p>While the wheels spin, listen for grinding or clicking. Those sounds usually mean the bearings need attention. A bearing service takes special tools, so most riders bring that work to a shop. Check that the quick-release levers close firmly, with the lever pointing back along the fork or frame. An open lever is an easy way to lose a wheel.</p>
<h3>Clean and lubricate the drivetrain</h3>
<p>Wipe down the chain with a rag. Shift through every gear to expose the full length. A chain that looks black or gritty needs degreaser. Spray or brush the degreaser on, let it sit for a minute, then scrub with an old toothbrush. Rinse with water and dry the chain completely.</p>
<p>Apply chain lube one drop at a time on the top run of the chain, turning the pedals backward to work it in. Wipe off the excess with a clean rag. Too much lube attracts dirt. The chain only needs oil between the rollers, not coating the outside. A properly lubed chain runs quietly and shifts smoothly.</p>
<p>Check the derailleur cables while you are at it. Frayed cables snap when you shift under load. Shift into the smallest cog on the back wheel and inspect where the cable enters the derailleur. Look for broken strands sticking out from the cable housing. Replace any cable with visible damage.</p>
<h3>Test the brakes and adjust the seat</h3>
<p>Squeeze each brake lever hard and hold it while you try to push the bike forward. The wheel should lock completely. Inspect the pads for grooves or uneven wear. Pads worn down to the indicator line need replacing before your first ride.</p>
<p>Finish with the saddle height. Stand next to the bike and raise the seat until the top is level with your hip bone. Sit on the bike and put your heel on the pedal at its lowest point. Your leg should be straight. A seat that is too low makes your knees hurt on long rides. Once the height feels right, tighten the seat post clamp and give the saddle a twist to confirm it will not move.</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;
}
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.
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 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;
}
}
Understand it
How it works and 6 properties
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.
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.
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. If the progress value matters, use a <progress> element with a real value, and keep the value current with 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 judgment call rather than a rule.
Do not use color alone for a section-based spy. The rail variant 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.
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 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.
Ship it
5 features in 4 browsers, 5 questions
Browser support
| Feature | ||||
|---|---|---|---|---|
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 are not in the Can I Use data this table is checked against, 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 not appear where the feature is missing.
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 rail variant, 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.
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 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()).