Skip to content
goodtabs UI
BrowseBrowse components

Reading progress

A 2px bar at the top of the page or a container that fills as you scroll.

npx shadcn@latest add https://ui.goodtabs.co/r/reading-progress.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/reading-progress

Why design systems shrink

Most design systems fail in the second year, not the first. The first year is launch energy: a palette, a type scale, twenty components and a Figma file that matches the code.

The second year is when product teams ship features faster than the system can absorb them. A new card variant here, a one-off modal there. Each is reasonable on its own.

The fix is not more governance. It is a smaller surface. Fewer tokens, fewer components, each with a clear reason to exist and an obvious place to put the next exception.

That is why the best systems read like a short style guide rather than a catalogue. They tell you what to do in the common case and stay out of the way in the rare one.

Measure the system by how often people reach for it without being told. If a team builds its own button, the problem is the button, not the team.

Pass a ref to a scrolling box and the bar sits at its top, following that scroll.

Thicker bar

  1. Create the project and pick a region close to your users.
  2. Add the environment variables from the dashboard. Secrets stay server side.
  3. Push to main. Every push builds a preview; the production branch deploys on green.
  4. Attach a domain. DNS usually settles within a few minutes.
  5. Turn on logs and alerts so the first incident is a notification, not a tweet.
  6. Invite the team and set roles. Viewers can read logs, members can deploy.

height={4} for a heavier line.

Window

Window mode

Without a container the bar is fixed to the top of the viewport and follows this page. Scroll to see it move. It stays hidden while the page is too short to scroll.

No container: the bar is fixed to the viewport and tracks this page.

Usage

import { ReadingProgress } from "@/components/ui/reading-progress"

// Page: fixed to the top of the viewport
<ReadingProgress />

// A scrolling panel: the parent is relative, the panel has the ref
<div className="relative">
  <ReadingProgress container={ref} />
  <div ref={ref} className="h-96 overflow-auto">...</div>
</div>

Props

PropTypeDefaultDescription
containerRefObject<HTMLElement | null>A scrolling element to track instead of the window.
position"fixed" | "absolute"container ? "absolute" : "fixed"fixed sits on the viewport; absolute at the top of the nearest positioned parent.
heightnumber2Bar thickness in px.
labelstring"Reading progress"Accessible name of the progressbar.

Source

reading-progress/reading-progress.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

type ReadingProgressProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** A scrolling element to track instead of the window. */
  container?: React.RefObject<HTMLElement | null>;
  /** fixed sits on the viewport, absolute at the top of the nearest positioned parent. Defaults to absolute when container is set. */
  position?: "fixed" | "absolute";
  /** Bar thickness in px. */
  height?: number;
  /** Accessible name. */
  label?: string;
};

/**
 * A thin bar at the top whose width follows how far the page, or a container, has scrolled.
 * It follows scroll directly with no transition, and only renders once there is something to scroll.
 */
export function ReadingProgress({
  container,
  position = container ? "absolute" : "fixed",
  height = 2,
  label = "Reading progress",
  className,
  style,
  ...props
}: ReadingProgressProps) {
  const [progress, setProgress] = React.useState<number | null>(null);

  React.useEffect(() => {
    const element = container?.current;
    const target: HTMLElement | Window = element ?? window;
    const read = () => {
      const max = element
        ? element.scrollHeight - element.clientHeight
        : document.documentElement.scrollHeight - window.innerHeight;
      if (max <= 0) return setProgress(null);
      const top = element ? element.scrollTop : window.scrollY;
      setProgress(Math.min(1, Math.max(0, top / max)));
    };

    read();
    target.addEventListener("scroll", read, { passive: true });
    const resize = new ResizeObserver(read);
    resize.observe(element ?? document.documentElement);
    if (element) for (const child of element.children) resize.observe(child);
    else window.addEventListener("resize", read);
    return () => {
      target.removeEventListener("scroll", read);
      resize.disconnect();
      if (!element) window.removeEventListener("resize", read);
    };
  }, [container]);

  if (progress === null) return null;

  return (
    <div
      role="progressbar"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={Math.round(progress * 100)}
      data-slot="reading-progress"
      className={cn(
        "pointer-events-none top-0 left-0 z-50 w-full origin-left bg-foreground",
        position === "fixed" ? "fixed" : "absolute",
        className,
      )}
      style={{ height, transform: `scaleX(${progress})`, ...style }}
      {...props}
    />
  );
}

For AI agents

Hand your agent this page, the markdown at /components/reading-progress/prompt.md, or the Copy AI prompt button above.

When to use

  • Long articles, docs and changelogs where a sense of how much is left helps
  • At the top of a scrolling panel, such as a sheet or a reading pane
  • When a bar is enough and a table of contents would be too much

When not to use

  • On short pages: it renders nothing until there is something to scroll, so it would never show
  • For task progress, use a progress bar with real steps
  • On app screens where the main scroll is not the content

Agent notes

  • It is a client component. The window version works from a server page; the container version needs a ref from a client component
  • In container mode the parent must be relative so the absolute bar sits at the top of the box, above the scrolling element
  • The width is a scaleX transform with no transition, so it tracks scroll exactly and needs no reduced-motion fallback
  • It renders null until the target can scroll, then a role="progressbar" with aria-valuenow 0 to 100