Loaders & Skeletons

Page Progress

Top-of-page route loading hairline with an imperative start/done store: a deterministic decelerating trickle toward 80%, then a fast sweep to 100% and fade.

Install

npx shadcn@latest add @paragon/page-progress

page-progress.tsx

"use client";

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

export type PageProgressStatus = "idle" | "loading" | "completing";

type Snapshot = { status: PageProgressStatus; generation: number };

let snapshot: Snapshot = { status: "idle", generation: 0 };
const serverSnapshot: Snapshot = { status: "idle", generation: 0 };
const listeners = new Set<() => void>();

function setStatus(status: PageProgressStatus) {
  if (snapshot.status === status) return;
  snapshot = {
    status,
    generation:
      status === "loading" ? snapshot.generation + 1 : snapshot.generation,
  };
  listeners.forEach((listener) => listener());
}

/** Component-internal: parks the bar after the completion sweep fades out. */
function settle() {
  setStatus("idle");
}

/**
 * Imperative controller for the route-loading hairline. Call from anywhere —
 * router events, fetch wrappers, form submissions:
 *
 *   pageProgress.start()  // begin (or restart) the trickle
 *   pageProgress.done()   // sweep to 100% and fade out
 */
export const pageProgress = {
  /** Begin (or restart) the trickle toward 80%. */
  start() {
    setStatus("loading");
  },
  /** Complete: fast sweep to 100%, then fade. No-op unless loading. */
  done() {
    if (snapshot.status === "loading") setStatus("completing");
  },
  subscribe(listener: () => void) {
    listeners.add(listener);
    return () => {
      listeners.delete(listener);
    };
  },
  getSnapshot: () => snapshot,
  getServerSnapshot: () => serverSnapshot,
};

/** Reflect the loader state in React (e.g. to disable navigation). */
export function usePageProgressStatus(): PageProgressStatus {
  return React.useSyncExternalStore(
    pageProgress.subscribe,
    () => pageProgress.getSnapshot().status,
    () => "idle",
  );
}

export interface PageProgressProps extends React.ComponentProps<"div"> {
  /** Bar thickness in px. */
  height?: number;
  /** Bar color. Defaults to the theme foreground. */
  color?: string;
}

/**
 * Top-of-page route loading hairline, driven by the `pageProgress` store.
 *
 * The trickle is deterministic — no randomness: every 400ms the bar closes a
 * fixed fraction of the distance to 80%, so the steps decelerate on a
 * geometric curve and every load looks identical. `done()` retargets the
 * still-running transition into a fast sweep to 100%, then fades the bar out.
 *
 * Renders fixed to the viewport top by default; pass `className="absolute"`
 * to scope it to a positioned container. Progress is written straight to the
 * element as `scaleX` (no per-frame React renders, no width animation).
 * Reduced motion keeps the opacity fades but steps the fill instantly.
 */
export function PageProgress({
  height = 2,
  color,
  className,
  style,
  ...props
}: PageProgressProps) {
  const { status, generation } = React.useSyncExternalStore(
    pageProgress.subscribe,
    pageProgress.getSnapshot,
    pageProgress.getServerSnapshot,
  );
  const reducedMotion = useReducedMotion() ?? false;
  const barRef = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    const bar = barRef.current;
    if (!bar) return;

    if (status === "loading") {
      // Reset instantly, then trickle in decelerating deterministic steps.
      bar.style.transition = "none";
      bar.style.opacity = "1";
      bar.style.transform = "scaleX(0)";
      void bar.getBoundingClientRect(); // flush the reset before stepping
      bar.style.transition = reducedMotion
        ? "opacity 150ms var(--ease-out)"
        : "transform 450ms var(--ease-out), opacity 150ms var(--ease-out)";
      let progress = 0;
      const step = () => {
        if (document.hidden) return; // don't advance in background tabs
        progress += (0.8 - progress) * 0.25;
        bar.style.transform = `scaleX(${progress.toFixed(4)})`;
      };
      step(); // respond immediately on start
      const interval = window.setInterval(step, 400);
      return () => window.clearInterval(interval);
    }

    if (status === "completing") {
      // Retarget the running transition: fast sweep to 100%, fade, settle.
      bar.style.transition = reducedMotion
        ? "opacity 200ms 100ms var(--ease-exit)"
        : "transform 200ms var(--ease-out), opacity 200ms 250ms var(--ease-exit)";
      bar.style.transform = "scaleX(1)";
      bar.style.opacity = "0";
      const timeout = window.setTimeout(settle, 500);
      return () => window.clearTimeout(timeout);
    }

    // Idle: park hidden at zero with no transition.
    bar.style.transition = "none";
    bar.style.opacity = "0";
    bar.style.transform = "scaleX(0)";
  }, [status, generation, reducedMotion]);

  return (
    <div
      aria-hidden
      data-slot="page-progress"
      className={cn("pointer-events-none fixed inset-x-0 top-0 z-50", className)}
      style={{ height, ...style }}
      {...props}
    >
      <div
        ref={barRef}
        className="h-full w-full origin-left"
        style={{
          transform: "scaleX(0)",
          opacity: 0,
          backgroundColor: color ?? "var(--color-foreground)",
        }}
      />
    </div>
  );
}