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.
npx shadcn@latest add @paragon/page-progress"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>
);
}