30% offEnding soon

CSS Scroll Indicator: An Interview-Ready Guide

18 min read

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.

Scrollable documentvisible areaviewportcurrent offsetavailable travellast possible topprogresscurrent ÷ available
Scroll progress compares the viewport’s current offset with its maximum possible travel.
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-marker represents 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.

Reading progressNative scrollbarreports valuemoves viewport
A reading-progress bar reports completion; a scrollbar also represents and controls the viewport.

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.

CSS timelinesupported?yesnoCSS paintsthe fillJavaScriptpaints the fillalwaysJS updates ARIA
Feature detection chooses who paints the fill; JavaScript maintains the semantic value either way.

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.

bar fillscroll position0%100%100%linear: equaleased
Linear easing keeps bar fill equal to scroll position across the entire page.

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.

Left originCenter originfills forwardgrows both ways
The transform origin decides where the scaled fill stays anchored.

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.

CSS-onlyCSS + JavaScriptvisual changesATaria-hiddensame valuebarARIAAT
Accessibility depends on whether the semantic value stays synchronized with the visible fill.

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.

CaseExpected resultWhat the implementation checks
Initial positionThe value is 0%The initial update reads a zero offset
Final positionThe value is 100%The offset equals the available range
Page without overflowThe value stays at 0%The fallback guards a zero or negative range
Fractional offsetThe bar changes smoothlyThe calculation keeps the fractional ratio
Bounce overscrollThe value remains from 0% to 100%The fallback clamps the ratio
Nested scrollerProgress follows the intended containerThe scroll source, listener, and dimensions all use that element
Alternate writing modeProgress follows the chosen logical axisCSS uses the block axis, while JavaScript must match the required direction
Injected content or loaded imagesProgress is recalculatedResizeObserver watches relevant document dimensions
Viewport resizeThe denominator is recalculatedThe resize listener runs the update
Unsupported scroll-driven CSSThe visual bar still fillsJavaScript sets the custom property
Framework unmountNo stale callback remainsListeners 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.

One sourceMixed sourcesnestedscrolleroffsetfullviewcorrect rationested offsetpage heightwrong ratio
Every input to a nested progress calculation must come from the same scroll source.

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.

Before insertAfter insertsame offset50%same offsetnewcontent33%
When content grows, unchanged scrollTop can still produce a lower progress ratio.

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.

Frequently asked questions

Can CSS create a scroll progress indicator without JavaScript?
Yes. A scroll-driven animation can map page progress to the horizontal scale of a fixed bar. Because browser support remains limited, a production implementation still needs a fallback.
How do you calculate scroll progress in JavaScript?
Divide scrollTop by scrollHeight minus clientHeight. Guard a zero or negative range, then clamp the result between 0 and 1 because scroll positions can be fractional or outside the normal range during overscroll.
Should a scroll indicator use the progressbar role?
Use role="progressbar" only when JavaScript keeps aria-valuenow synchronized with the visible value. Keep a CSS-only indicator decorative because CSS cannot update that attribute.
How do you build a scroll indicator for a nested container?
Use the nested element as the scroll source and calculate progress from that element's scrollTop, scrollHeight, and clientHeight. A bare scroll() timeline chooses the nearest ancestor scroll container, while scroll(root block) explicitly tracks the page.
What happens when a page has no scrollable content?
The available scroll range is zero, so the CSS scroll timeline is inactive. A JavaScript fallback must guard that case instead of dividing by zero.