A CSS scroll indicator is a fixed reading-progress bar that grows as the page moves from its first scroll position to its last. The modern CSS solution uses a scroll-driven animation, while JavaScript supplies a fallback and synchronized accessibility information.
A CSS scroll indicator maps the current scroll offset to a bar that fills from 0% at the start of the page to 100% at the end.
What a CSS Scroll Indicator Measures
A reading-progress indicator answers one question: how far has the reader moved through the available scroll range?
The calculation has two parts. The current scroll offset is the distance from the start. The available scroll range is the full content height minus the visible container height.
progress = current scroll offset / available scroll range
At the start, the offset is zero, so the result is 0%. At the last scroll position, the offset equals the available range, so the result is 100%.
This indicator is separate from several controls that can look similar:
- A native scrollbar shows the viewport's position and relative size, and it also lets the user move through the content.
- A scroll-down cue suggests that more content exists below the fold.
- A scroll shadow hints that content continues beyond an edge.
- A
::scroll-markerrepresents a navigation target rather than continuous reading progress. - A reading-progress bar displays one value from the start to the end of a document or container.
The distinction matters in an interview. If the prompt asks for reading progress, section dots or a custom scrollbar solve different problems.
The older CSS technique paints a diagonal gradient across the page and hides part of it beneath a header. It can produce the right visual result, but its geometry depends on content height and fixed offsets. Scroll-driven animation expresses the actual relationship directly, so it is the better primary solution.
Build the Indicator With Scroll-Driven CSS
Start with a fixed track and a fill element. The fill begins at scaleX(0) and expands horizontally to scaleX(1).
This is a complete standalone CSS-only example. The indicator is decorative because CSS cannot update an aria-valuenow attribute.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSS scroll indicator</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 1rem/1.6 system-ui, sans-serif;
color: #1d2433;
background: #ffffff;
}
.scroll-indicator {
position: fixed;
inset: 0 0 auto;
z-index: 10;
height: 0.35rem;
background: #d8deea;
}
.scroll-indicator__fill {
width: 100%;
height: 100%;
background: linear-gradient(90deg, #3157d5, #7436c8);
transform: scaleX(0);
transform-origin: left center;
}
@keyframes fill-scroll-indicator {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@supports (animation-timeline: scroll(root block)) {
.scroll-indicator__fill {
animation: fill-scroll-indicator linear both;
animation-timeline: scroll(root block);
}
}
main {
width: min(70ch, calc(100% - 2rem));
margin: 0 auto;
padding: 4rem 0;
}
</style>
</head>
<body>
<div class="scroll-indicator" aria-hidden="true">
<div class="scroll-indicator__fill"></div>
</div>
<main>
<h1>Scroll-driven reading progress</h1>
<p>Add enough article content here to make the document scroll.</p>
</main>
</body>
</html>
The track is fixed to the top edge with position: fixed and inset. Its neutral background shows the unfilled distance. The fill uses a gradient, although a solid color works just as well.
The z-index places the track above ordinary document content. A real interface should compare that value with its existing header, dialog, and menu layers rather than choosing an arbitrarily large number.
The animation shorthand appears before animation-timeline. This order is required because the shorthand resets an earlier timeline declaration to auto.
The @supports block is the progressive-enhancement boundary. Browsers that understand the declaration attach the animation to the root page's block-axis scroll progress. Other browsers retain the visible track and the fill's initial scale until the JavaScript fallback updates it.
MDN classifies animation-timeline and scroll() as limited-availability features. Recheck current compatibility before publishing a project, and avoid treating the CSS path as universal.
For a focused implementation with React and JavaScript variants, compare the scroll progress indicator guide.
How the Scroll Timeline Fills the Bar
A scroll-progress timeline maps the startmost scroll position to 0% and the endmost position to 100%. The browser derives the progress from the current offset and the available scrollable overflow.
The key declaration is:
animation-timeline: scroll(root block);
root chooses the document's root scroller. block chooses the logical block axis, which is the direction in which blocks flow in the current writing mode. On a typical horizontal writing mode, that corresponds to vertical page scrolling.
These arguments make the source explicit. Calling scroll() without arguments chooses the nearest ancestor scroll container and its block axis. That default is useful for a component inside an overflow container, but it can quietly measure the wrong element when the requested result is page progress.
The animation uses linear easing because reading progress should map directly to scroll progress. An eased animation would make the displayed value run ahead of or behind the measured position.
transform-origin: left center anchors the fill at its left edge. Without that declaration, scaleX() expands around the element's default center point. For a right-to-left interface, the design may need a right-side origin. Alternate writing modes may call for a vertical bar and scaleY() instead.
The transform changes the rendered scale without repeatedly assigning a new width. At timeline progress 0%, the keyframe supplies scaleX(0). At 100%, it supplies scaleX(1).
When the starting and ending scroll positions coincide, the page has no available scroll range. The specification makes that timeline inactive. JavaScript must handle the same condition explicitly because division by zero does not produce a useful progress value.
Add a JavaScript Fallback
The canonical implementation below keeps the CSS scroll-driven animation when it is supported. JavaScript updates the visual fill only when that CSS feature is unavailable. It always updates aria-valuenow, which makes the semantic progress value agree with the page position.
Replace the standalone example with this complete document:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Accessible scroll indicator</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 1rem/1.6 system-ui, sans-serif;
color: #1d2433;
background: #ffffff;
}
.scroll-indicator {
position: fixed;
inset: 0 0 auto;
z-index: 10;
height: 0.35rem;
background: #d8deea;
}
.scroll-indicator__fill {
width: 100%;
height: 100%;
background: linear-gradient(90deg, #3157d5, #7436c8);
transform: scaleX(var(--scroll-progress, 0));
transform-origin: left center;
transition: transform 100ms linear;
}
@keyframes fill-scroll-indicator {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@supports (animation-timeline: scroll(root block)) {
.scroll-indicator__fill {
animation: fill-scroll-indicator linear both;
animation-timeline: scroll(root block);
transition: none;
}
}
@media (prefers-reduced-motion: reduce) {
.scroll-indicator__fill {
transition: none;
}
}
main {
width: min(70ch, calc(100% - 2rem));
margin: 0 auto;
padding: 4rem 0;
}
</style>
</head>
<body>
<div
class="scroll-indicator"
role="progressbar"
aria-label="Reading progress"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
>
<div class="scroll-indicator__fill"></div>
</div>
<main>
<h1>Accessible reading progress</h1>
<p>Add the article content here.</p>
</main>
<script>
const indicator = document.querySelector(".scroll-indicator");
const fill = document.querySelector(".scroll-indicator__fill");
const scroller = document.scrollingElement;
const cssTimelineSupported = CSS.supports(
"animation-timeline: scroll(root block)"
);
function getScrollProgress() {
const scrollRange = scroller.scrollHeight - scroller.clientHeight;
if (scrollRange <= 0) {
return 0;
}
const rawProgress = scroller.scrollTop / scrollRange;
return Math.min(1, Math.max(0, rawProgress));
}
function updateScrollIndicator() {
const progress = getScrollProgress();
const percentage = Math.round(progress * 100);
indicator.setAttribute("aria-valuenow", String(percentage));
if (!cssTimelineSupported) {
fill.style.setProperty("--scroll-progress", String(progress));
}
}
const resizeObserver = new ResizeObserver(updateScrollIndicator);
window.addEventListener("scroll", updateScrollIndicator);
window.addEventListener("resize", updateScrollIndicator);
resizeObserver.observe(document.documentElement);
if (document.body) {
resizeObserver.observe(document.body);
}
updateScrollIndicator();
window.addEventListener("pagehide", () => {
window.removeEventListener("scroll", updateScrollIndicator);
window.removeEventListener("resize", updateScrollIndicator);
resizeObserver.disconnect();
});
</script>
</body>
</html>
In standards mode, document.scrollingElement returns the document root element that scrolls the page. Its scrollHeight measures the full content height, including content outside the visible area. Subtracting clientHeight gives the available vertical scroll range.
The guard returns zero when that range is zero or negative. The clamp then protects both ends of a normal range. This matters because scrollTop can contain a fractional value, and Safari bounce overscroll can report a value beyond the usual limits.
The handler performs a short calculation and two small updates. Scroll events can fire at a high rate, so expensive layout or application work does not belong there. Wrapping every scroll callback in requestAnimationFrame() would not by itself throttle the event.
ResizeObserver runs the calculation again when observed dimensions change. This covers many changes caused by inserted content or loaded media. The resize listener handles viewport changes. The first direct call initializes the value even when the page opens at a restored scroll position.
A custom hook such as useScrollPosition can package the subscription in a framework, but the interview concept remains the same calculation. More complex loading behavior may also connect this pattern to useInfiniteScroll or a virtualized list.
Make Reading Progress Accessible
A purely visual indicator should stay out of the accessibility tree. The standalone CSS example does this with aria-hidden="true" on the track.
Do not add role="progressbar" to that version. CSS changes the rendered transform, but it cannot synchronize aria-valuenow. Exposing a value that remains at zero while the bar fills gives assistive technology incorrect information.
The canonical JavaScript implementation can use progress semantics because it updates the attribute on every calculation. A determinate progress bar needs an accessible name and a current value. Its implicit minimum and maximum are 0 and 100, although the example declares both values to make the scale visible in the markup.
Color contrast still matters for a visual indicator. Choose track and fill colors that remain distinguishable against the page and any fixed header beneath them. Do not rely on a subtle color difference as the only visible state.
prefers-reduced-motion: reduce reports a request to reduce or replace nonessential motion. The example removes the fallback transition, so the bar changes directly with the scroll position. It does not remove the progress information itself. A product can hide a decorative bar under this preference, but every progress bar does not need to disappear.
The UIReady Premium Lifetime practice library can help when preparing this implementation alongside timed UI and accessibility exercises.
Edge Cases Interviewers Will Test
A strong answer includes the test plan before the interviewer asks for it.
| Case | Expected result | What the implementation checks |
|---|---|---|
| Initial position | The value is 0% | The initial update reads a zero offset |
| Final position | The value is 100% | The offset equals the available range |
| Page without overflow | The value stays at 0% | The fallback guards a zero or negative range |
| Fractional offset | The bar changes smoothly | The calculation keeps the fractional ratio |
| Bounce overscroll | The value remains from 0% to 100% | The fallback clamps the ratio |
| Nested scroller | Progress follows the intended container | The scroll source, listener, and dimensions all use that element |
| Alternate writing mode | Progress follows the chosen logical axis | CSS uses the block axis, while JavaScript must match the required direction |
| Injected content or loaded images | Progress is recalculated | ResizeObserver watches relevant document dimensions |
| Viewport resize | The denominator is recalculated | The resize listener runs the update |
| Unsupported scroll-driven CSS | The visual bar still fills | JavaScript sets the custom property |
| Framework unmount | No stale callback remains | Listeners are removed and observers are disconnected |
Nested containers require a deliberate substitution. Listen for scroll events on the container, read that same element's scrollTop, scrollHeight, and clientHeight, and observe its dimensions. Mixing a container's offset with the document's height produces a plausible bar with the wrong value.
A bare scroll() may be suitable when the indicator sits inside the intended scroll container. Use scroll(root block) when the requirement is explicitly page progress.
Horizontal scrolling needs a separate formula based on horizontal offset, full width, and visible width. Alternate writing modes make logical directions more important than assumptions about vertical and horizontal axes. State which axis the interface measures before writing the code.
Dynamic content can move the apparent reading position even when scrollTop has not changed. An expanded accordion, an injected result, or a loaded image can increase scrollHeight, which lowers the current ratio. Recalculate after those size changes. The useElementBounding exercise covers related element measurement work.
In React, Vue, or another component framework, put listener removal and ResizeObserver.disconnect() in the component's cleanup path. The example uses pagehide because it is a standalone document, but component lifetime is narrower than page lifetime.