30% offEnding soon

Scroll Progress Indicator: JavaScript and React

12 min read

A scroll progress indicator shows how far a document or scroll container has moved through its available scroll range. A reliable implementation calculates the value immediately, clamps overscroll, responds when the content size changes, and removes its subscriptions during cleanup.

A scroll progress indicator divides the current scroll offset by the available scroll distance, clamps the result from 0 to 100 percent, and hides itself when no scrollable distance exists.

The Interview Prompt and Requirements

Build a horizontal scroll progress indicator for a document or a supplied scroll container. The component reports position only. It does not load content, drag the viewport, or replace the browser scrollbar.

The functional requirements are:

  • Show progress from 0 to 100 percent.
  • Track either the document or one supplied element.
  • Calculate the initial value as soon as the component starts.
  • Update when scrolling changes the current position.
  • Recalculate when content or viewport changes alter the scroll range.
  • Hide the indicator when the content has no vertical overflow.
  • Clamp negative offsets and offsets beyond the normal maximum.

The cleanup requirement is equally important. Every scroll listener, resize listener, and observer created during setup must be removed during teardown. This makes the controller safe to mount repeatedly and lets a React Effect survive its development cleanup check.

This prompt tests more than event handling. It asks for a pure calculation, careful DOM measurement, dynamic resizing, accessibility, and subscription ownership. Those concerns also appear in exercises about reading scroll position and building an infinite scroll list.

Deriving the Scroll Progress Formula

Three measurements define vertical progress:

  • scrollTop is the current vertical scroll offset.
  • scrollHeight is the height of the entire content, including the part hidden by overflow.
  • clientHeight is the visible inner height of the scrolling element.
visible areaclientHeightscrollHeightscrollTopscrolldistancebottommostviewport
The scroll offset, viewport height, and total content height define the available scroll distance.

The available scroll distance is:

scrollDistance = scrollHeight - clientHeight

Progress is the current offset divided by that distance:

progress = scrollTop / scrollDistance

The raw result needs two guards. First, a distance of zero or less means there is nowhere to scroll. The function returns 0, and the rendering layer hides the track. Second, scrollTop can be negative or exceed the usual maximum during overscroll, so the result must be clamped.

function calculateScrollProgress(scrollTop, scrollHeight, clientHeight) {
  const scrollDistance = scrollHeight - clientHeight;

  if (scrollDistance <= 0) {
    return 0;
  }

  return Math.min(1, Math.max(0, scrollTop / scrollDistance));
}

This helper returns a ratio from 0 to 1. Keeping it as a ratio matches transform: scaleX() directly. Multiply by 100 only when displaying a percentage or updating aria-valuenow.

Keep the no-overflow rule in one place: a non-positive distance produces 0, while the controller hides the indicator. Do not show a full bar in that case. A value of 100 percent means the scroller reached the end of an actual range.

Build the Indicator Markup and Styles

The track stays fixed at the top of the viewport. The fill starts at full width but uses a horizontal transform to display the measured fraction.

<div class="scroll-progress" data-scroll-progress aria-hidden="true">
  <div class="scroll-progress__fill" data-scroll-progress-fill></div>
</div>
.scroll-progress {
  position: fixed;
  inset: 0 0 auto;
  height: 4px;
  overflow: hidden;
  pointer-events: none;
  z-index: 10;
}

.scroll-progress[hidden] {
  display: none;
}

.scroll-progress__fill {
  width: 100%;
  height: 100%;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left center;
  will-change: transform;
}

@media (prefers-reduced-motion: reduce) {
  .scroll-progress__fill {
    transition: none;
  }
}

A left-side transform origin pins zero progress to the left edge. Linear scaling then gives a direct visual mapping: a ratio of 0.4 displays 40 percent of the fill.

The example is decorative, so its wrapper has aria-hidden="true". This avoids announcing every small scroll change.

A product may instead treat reading progress as meaningful status. In that case, remove aria-hidden and use a named determinate progress bar:

<div
  data-scroll-progress
  role="progressbar"
  aria-label="Article reading progress"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuenow="0"
>
  <div data-scroll-progress-fill></div>
</div>

The controller must update aria-valuenow with a value between the declared minimum and maximum. Choose one accessibility mode deliberately. Do not combine aria-hidden with the progressbar role.

Vanilla JavaScript Solution

The controller below reuses the pure helper. It supports the document and nested element scrollers without mixing root and body measurements.

For a nested scroller with a fixed height, pass a content wrapper through content. The wrapper changes size when images, fetched results, or other children change the scrollable content.

function calculateScrollProgress(scrollTop, scrollHeight, clientHeight) {
  const scrollDistance = scrollHeight - clientHeight;

  if (scrollDistance <= 0) {
    return 0;
  }

  return Math.min(1, Math.max(0, scrollTop / scrollDistance));
}

function createScrollProgressController({
  track,
  fill,
  scroller = document,
  content,
  semantic = false,
}) {
  const usesDocument = scroller === document;
  const scrollingElement = usesDocument
    ? document.scrollingElement || document.documentElement
    : scroller;
  const eventTarget = usesDocument ? document : scroller;
  const observedContent = content || scrollingElement;

  let resizeObserver = null;
  let destroyed = false;

  function measure() {
    if (destroyed) {
      return;
    }

    const {
      scrollTop,
      scrollHeight,
      clientHeight,
    } = scrollingElement;

    const scrollDistance = scrollHeight - clientHeight;
    const progress = calculateScrollProgress(
      scrollTop,
      scrollHeight,
      clientHeight
    );

    track.hidden = scrollDistance <= 0;
    fill.style.transform = `scaleX(${progress})`;

    if (semantic) {
      track.setAttribute(
        "aria-valuenow",
        String(Math.round(progress * 100))
      );
    }
  }

  eventTarget.addEventListener("scroll", measure, { passive: true });
  window.addEventListener("resize", measure);

  if ("ResizeObserver" in window) {
    resizeObserver = new ResizeObserver(measure);
    resizeObserver.observe(observedContent);

    if (observedContent !== scrollingElement) {
      resizeObserver.observe(scrollingElement);
    }
  }

  measure();

  return {
    measure,
    destroy() {
      if (destroyed) {
        return;
      }

      destroyed = true;
      eventTarget.removeEventListener("scroll", measure);
      window.removeEventListener("resize", measure);
      resizeObserver?.disconnect();
    },
  };
}

Create a document controller by passing the rendered track and fill:

const track = document.querySelector("[data-scroll-progress]");
const fill = document.querySelector("[data-scroll-progress-fill]");

const controller = createScrollProgressController({
  track,
  fill,
  semantic: true,
});

A nested container needs its own scroll element. Wrapping the scrollable children gives the observer an element whose size follows the content.

const controller = createScrollProgressController({
  track: document.querySelector("[data-panel-progress]"),
  fill: document.querySelector("[data-panel-progress-fill]"),
  scroller: document.querySelector("[data-scroll-panel]"),
  content: document.querySelector("[data-scroll-panel-content]"),
});

Call controller.destroy() when the indicator is removed. Calling destroy() twice is harmless because the controller records its destroyed state.

The immediate measure() call covers restored scroll positions and anchor-linked pages. Waiting for the first scroll event would leave the bar at zero until the reader moves again.

React Scroll Progress Indicator

A React hook owns the progress state and every subscription. Browser globals stay inside the Effect, so rendering does not require document or window.

import { useEffect, useState } from "react";

function calculateScrollProgress(scrollTop, scrollHeight, clientHeight) {
  const scrollDistance = scrollHeight - clientHeight;

  if (scrollDistance <= 0) {
    return 0;
  }

  return Math.min(1, Math.max(0, scrollTop / scrollDistance));
}

function useScrollProgress(containerRef, contentRef) {
  const [state, setState] = useState({
    progress: 0,
    hasOverflow: false,
  });

  useEffect(() => {
    const suppliedScroller = containerRef?.current;
    const usesDocument = !suppliedScroller;
    const scroller = usesDocument
      ? document.scrollingElement || document.documentElement
      : suppliedScroller;
    const eventTarget = usesDocument ? document : scroller;
    const observedContent = contentRef?.current || scroller;

    let resizeObserver = null;

    function measure() {
      const scrollDistance =
        scroller.scrollHeight - scroller.clientHeight;
      const progress = calculateScrollProgress(
        scroller.scrollTop,
        scroller.scrollHeight,
        scroller.clientHeight
      );

      setState((current) => {
        const next = {
          progress,
          hasOverflow: scrollDistance > 0,
        };

        if (
          current.progress === next.progress &&
          current.hasOverflow === next.hasOverflow
        ) {
          return current;
        }

        return next;
      });
    }

    eventTarget.addEventListener("scroll", measure, { passive: true });
    window.addEventListener("resize", measure);

    if ("ResizeObserver" in window) {
      resizeObserver = new ResizeObserver(measure);
      resizeObserver.observe(observedContent);

      if (observedContent !== scroller) {
        resizeObserver.observe(scroller);
      }
    }

    measure();

    return () => {
      eventTarget.removeEventListener("scroll", measure);
      window.removeEventListener("resize", measure);
      resizeObserver?.disconnect();
    };
  }, [containerRef, contentRef]);

  return state;
}

The component turns that state into a decorative indicator:

function ScrollProgressIndicator({
  containerRef,
  contentRef,
}) {
  const { progress, hasOverflow } = useScrollProgress(
    containerRef,
    contentRef
  );

  if (!hasOverflow) {
    return null;
  }

  return (
    <div className="scroll-progress" aria-hidden="true">
      <div
        className="scroll-progress__fill"
        style={{ transform: `scaleX(${progress})` }}
      />
    </div>
  );
}

React Effects run on the client. Their cleanup runs before an Effect is set up again with changed dependencies and after the component is removed. Development Strict Mode also performs an extra setup and cleanup cycle. The mirrored cleanup above prevents duplicate listeners and observers in each case.

A related interview may ask for a reusable element bounding hook or a stable callback such as useEventCallback. Those exercises test the same boundary between browser subscriptions and React state.

Handle Performance, Resizing, and Accessibility

Document scroll events can fire at a high rate. Keep the handler small: read the three measurements, run arithmetic, and change one transform or one React state object.

Using requestAnimationFrame alone is not a throttle because animation callbacks and scroll events can occur at approximately the same rate. If the interview requires a lower update frequency, add an explicit time-based throttle and explain the visual tradeoff. The useRafState exercise is useful for practising frame-scheduled state, but it does not remove the need to reason about event frequency.

transform: scaleX() avoids repeatedly assigning a new width. The fill has one stable layout width, and the transform changes its displayed scale.

A complete implementation must cover these cases:

  • A restored or anchor-linked position is measured during setup.
  • Negative scrollTop values clamp to zero.
  • Values beyond the maximum clamp to one.
  • Zero overflow returns zero and hides the track.
  • A viewport resize triggers another measurement.
  • A ResizeObserver watches the relevant content element for size changes.
  • A nested container supplies its own scrollTop, scrollHeight, and clientHeight.
  • Cleanup removes the exact listeners and observers created during setup.

Resize observation needs the right target. Observing a fixed-height nested scroller may not report a change when only its overflowing children grow. Pass an inner content wrapper whose box changes with those children.

Reduced motion usually requires no special animation because the fill maps directly to scroll position. If decorative transitions are added, disable them under prefers-reduced-motion.

Keep scroll progress distinct from loading progress. Loading progress describes an operation that has not finished. Scroll progress describes a position within content that already exists. A draggable scrollbar also controls position, while this component only reports it.

If the bar is decorative, hide it from assistive technology. If it is semantic, give it an accessible name and update aria-valuenow. Avoid a live region that announces each movement.

For additional component and test practice, UIReady Premium lifetime access groups related interview exercises beyond this single indicator.

CSS-Only Scroll Progress

CSS scroll-driven animation can connect the fill to the root document without JavaScript state.

@keyframes reveal-scroll-progress {
  from {
    transform: scaleX(0);
  }

  to {
    transform: scaleX(1);
  }
}

.scroll-progress__fill {
  width: 100%;
  height: 100%;
  transform-origin: left center;
  animation: reveal-scroll-progress linear;
  animation-timeline: scroll(root block);
}

The timeline maps the startmost scroll position to 0 percent animation progress and the endmost position to 100 percent. Declare animation-timeline after the animation shorthand because the shorthand resets the timeline to auto.

Use this as progressive enhancement when no JavaScript value, callback, or semantic value update is required. animation-timeline has limited availability, so retain the JavaScript implementation when the prompt requires broader compatibility, numeric state, callbacks, or direct behavior tests.

The CSS option also needs the same visual distinction from loading progress. It reports scroll position and does not control the viewport.

Tests and Interview Follow-Ups

Start with unit tests for the pure formula. The following self-contained checks cover the main boundaries.

function calculateScrollProgress(scrollTop, scrollHeight, clientHeight) {
  const distance = scrollHeight - clientHeight;
  if (distance <= 0) return 0;
  return Math.min(1, Math.max(0, scrollTop / distance));
}

const cases = [
  ["top", 0, 1000, 500, 0],
  ["midpoint", 250, 1000, 500, 0.5],
  ["bottom", 500, 1000, 500, 1],
  ["no overflow", 0, 500, 500, 0],
  ["negative offset", -50, 1000, 500, 0],
  ["excessive offset", 700, 1000, 500, 1],
];

for (const [name, top, height, viewport, expected] of cases) {
  const actual = calculateScrollProgress(top, height, viewport);
  console.log(`${name}: ${actual === expected}`);
}
top: true
midpoint: true
bottom: true
no overflow: true
negative offset: true
excessive offset: true

The following Vitest package executes those component checks, including restored document position, nested content growth, viewport resizing, semantic updates, idempotent cleanup, and React Strict Mode setup and cleanup.

import React, { StrictMode, useLayoutEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createScrollProgressController } from "./scroll-progress.js";

let observers;

class ResizeObserverMock {
  constructor(callback) {
    this.callback = callback;
    this.observe = vi.fn();
    this.disconnect = vi.fn();
    observers.push(this);
  }
}

function setMetrics(element, values) {
  for (const [name, value] of Object.entries(values)) {
    Object.defineProperty(element, name, {
      configurable: true,
      value,
    });
  }
}

function createIndicator() {
  const track = document.createElement("div");
  const fill = document.createElement("div");
  track.append(fill);
  document.body.append(track);
  return { track, fill };
}

beforeEach(() => {
  observers = [];
  vi.stubGlobal("ResizeObserver", ResizeObserverMock);
});

afterEach(() => {
  document.body.replaceChildren();
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
});

describe("createScrollProgressController", () => {
  it("measures a restored document position and reacts to viewport resizing", () => {
    const { track, fill } = createIndicator();
    const root = document.scrollingElement;
    setMetrics(root, {
      scrollTop: 250,
      scrollHeight: 1000,
      clientHeight: 500,
    });

    const controller = createScrollProgressController({
      track,
      fill,
      semantic: true,
    });

    expect(fill.style.transform).toBe("scaleX(0.5)");
    expect(track.getAttribute("aria-valuenow")).toBe("50");

    setMetrics(root, { clientHeight: 750 });
    window.dispatchEvent(new Event("resize"));

    expect(fill.style.transform).toBe("scaleX(1)");
    expect(track.getAttribute("aria-valuenow")).toBe("100");
    controller.destroy();
  });

  it("measures a nested scroller again when observed content grows", () => {
    const { track, fill } = createIndicator();
    const scroller = document.createElement("div");
    const content = document.createElement("div");
    scroller.append(content);
    document.body.append(scroller);
    setMetrics(scroller, {
      scrollTop: 100,
      scrollHeight: 500,
      clientHeight: 300,
    });

    const controller = createScrollProgressController({
      track,
      fill,
      scroller,
      content,
      semantic: true,
    });

    expect(fill.style.transform).toBe("scaleX(0.5)");
    expect(track.getAttribute("aria-valuenow")).toBe("50");
    expect(observers[0].observe).toHaveBeenCalledWith(content);

    setMetrics(scroller, { scrollHeight: 700 });
    observers[0].callback([{ target: content }], observers[0]);

    expect(fill.style.transform).toBe("scaleX(0.25)");
    expect(track.getAttribute("aria-valuenow")).toBe("25");
    controller.destroy();
  });

  it("removes listeners, disconnects its observer, and tolerates repeated teardown", () => {
    const { track, fill } = createIndicator();
    const scroller = document.createElement("div");
    document.body.append(scroller);
    setMetrics(scroller, {
      scrollTop: 0,
      scrollHeight: 600,
      clientHeight: 300,
    });
    const removeScroll = vi.spyOn(scroller, "removeEventListener");
    const removeResize = vi.spyOn(window, "removeEventListener");

    const controller = createScrollProgressController({ track, fill, scroller });
    const observer = observers[0];

    controller.destroy();
    controller.destroy();

    expect(removeScroll).toHaveBeenCalledTimes(1);
    expect(removeScroll).toHaveBeenCalledWith("scroll", controller.measure);
    expect(removeResize).toHaveBeenCalledTimes(1);
    expect(removeResize).toHaveBeenCalledWith("resize", controller.measure);
    expect(observer.disconnect).toHaveBeenCalledTimes(1);
  });

  it("balances every setup and cleanup under React Strict Mode", () => {
    const scroller = document.createElement("div");
    document.body.append(scroller);
    setMetrics(scroller, {
      scrollTop: 0,
      scrollHeight: 600,
      clientHeight: 300,
    });
    const addScroll = vi.spyOn(scroller, "addEventListener");
    const removeScroll = vi.spyOn(scroller, "removeEventListener");

    function Harness() {
      const trackRef = useRef(null);
      const fillRef = useRef(null);

      useLayoutEffect(() => {
        const controller = createScrollProgressController({
          track: trackRef.current,
          fill: fillRef.current,
          scroller,
        });
        return () => controller.destroy();
      }, []);

      return (
        <div ref={trackRef}>
          <div ref={fillRef} />
        </div>
      );
    }

    const host = document.createElement("div");
    document.body.append(host);
    const root = createRoot(host);

    act(() => root.render(<StrictMode><Harness /></StrictMode>));
    act(() => root.unmount());

    expect(addScroll.mock.calls.filter(([type]) => type === "scroll").length).toBeGreaterThan(0);
    expect(removeScroll.mock.calls.filter(([type]) => type === "scroll").length).toBe(
      addScroll.mock.calls.filter(([type]) => type === "scroll").length,
    );
    expect(observers.every((observer) => observer.disconnect.mock.calls.length === 1)).toBe(true);
  });
});

Likely follow-ups include horizontal progress, progress limited to an article region, and integration with a virtualized list.

Frequently asked questions

How do you calculate scroll progress in JavaScript?
Subtract clientHeight from scrollHeight to find the available scroll distance. Divide scrollTop by that distance, clamp the result between 0 and 1, then multiply by 100 when a percentage is needed.
What should a scroll progress indicator show when the page does not scroll?
Return 0 and hide the indicator when scrollHeight minus clientHeight is zero or negative. A full bar would incorrectly suggest that the reader reached the end of scrollable content.
Should a scroll progress indicator use ARIA progressbar?
Use aria-hidden when the indicator is decorative and the same information is available from the scrollbar. If the value has a deliberate user-facing purpose, give it the progressbar role, an accessible name, and an updated aria-valuenow value.
Can CSS create a scroll progress indicator without JavaScript?
CSS scroll-driven animations can connect scaleX() to a scroll progress timeline. Because animation-timeline has limited availability, JavaScript remains useful when compatibility, numeric state, callbacks, or automated behavior tests are required.