A thin page bar that trickles, finishes fast and fades; skips fast loads.
Loading & progress@base-ui/react
01Preview
northwind / Overview
Overview
- Requests today
- 1.2M
- Error rate
- 0.04%
- p95 latency
- 182 ms
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react03Usage
import { TopLoader, useTopLoader } from "@/components/ui/top-loader";
const loader = useTopLoader();
<TopLoader loading={loader.loading} error={loader.error} />
// Around any request. Overlapping calls keep one bar going until the last settles.
await loader.track(fetch("/api/projects"));
// Or by hand
loader.start();
loader.done(); // or loader.fail()04Source
"use client";
import { Progress } from "@base-ui/react/progress";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/cn";
type Phase = "idle" | "waiting" | "trickling" | "finishing" | "fading";
/**
* start() / done() / fail() with a counter, so overlapping requests keep one bar
* running until the last of them settles. track(promise) does both for you.
*/
export function useTopLoader() {
const [count, setCount] = useState(0);
const [error, setError] = useState(false);
const start = useCallback(() => {
setError(false);
setCount((c) => c + 1);
}, []);
const done = useCallback(() => setCount((c) => Math.max(0, c - 1)), []);
const fail = useCallback(() => {
setError(true);
setCount((c) => Math.max(0, c - 1));
}, []);
const track = useCallback(
<T,>(promise: Promise<T>) => {
start();
promise.then(done, fail);
return promise;
},
[start, done, fail],
);
const loading = count > 0;
return useMemo(() => ({ loading, error, start, done, fail, track }), [loading, error, start, done, fail, track]);
}
export type TopLoaderProps = Omit<React.ComponentProps<"div">, "children"> & {
/** True while the page (or anything) is loading. Flip it back to false to finish. */
loading: boolean;
/** Finish in the danger color, for a navigation that failed. */
error?: boolean;
/** Milliseconds before the bar appears. Loads that finish sooner never show it. */
delay?: number;
/** fixed pins it to the top of the viewport; absolute to the top of the nearest positioned parent. */
position?: "fixed" | "absolute";
/** Accessible name while visible. */
label?: string;
};
// Where the trickle heads: fast at first, slowing as it nears 94%, never arriving
// on its own. Each step covers a share of the remaining distance.
const nextTrickle = (p: number, step: number) => Math.min(0.94, p + (0.94 - p) * (0.1 + (step % 3) * 0.04));
export function TopLoader({ loading, error = false, delay = 120, position = "fixed", label = "Loading page", className, style, ...rest }: TopLoaderProps) {
const [phase, setPhase] = useState<Phase>(loading ? "waiting" : "idle");
const [p, setP] = useState(0);
const [prev, setPrev] = useState(loading);
const steps = useRef(0);
// Follow the prop on the render it changes.
if (loading !== prev) {
setPrev(loading);
if (loading) {
if (phase === "idle") setPhase("waiting");
else if (phase === "finishing" || phase === "fading") {
// A new load while the last one is leaving: start again from the left, unanimated.
setP(0);
setPhase("waiting");
}
} else if (phase === "waiting") setPhase("idle");
else if (phase === "trickling") {
setP(1);
setPhase("finishing");
}
}
useEffect(() => {
if (phase === "waiting") {
const id = window.setTimeout(() => {
steps.current = 0;
setP(0.08);
setPhase("trickling");
}, delay);
return () => window.clearTimeout(id);
}
if (phase === "trickling") {
const id = window.setInterval(() => {
// Don't creep forward in a hidden tab; nobody is watching and it would arrive stale.
if (document.hidden) return;
steps.current += 1;
setP((x) => nextTrickle(x, steps.current));
}, 480);
return () => window.clearInterval(id);
}
if (phase === "finishing") {
const id = window.setTimeout(() => setPhase("fading"), error ? 520 : 240);
return () => window.clearTimeout(id);
}
if (phase === "fading") {
const id = window.setTimeout(() => {
setP(0);
setPhase("idle");
}, 320);
return () => window.clearTimeout(id);
}
}, [phase, delay, error]);
const visible = phase === "trickling" || phase === "finishing";
// Reset to 0 happens while invisible, so it must not animate backwards.
const moving = phase === "trickling" ? "duration-[600ms]" : phase === "finishing" ? "duration-200" : "duration-0";
return (
<Progress.Root
value={visible ? Math.round(p * 100) : null}
aria-label={label}
aria-hidden={!visible || undefined}
data-state={phase}
data-error={error || undefined}
className={cn(
"pointer-events-none inset-x-0 top-0 z-(--z-toast) h-0.5 overflow-hidden transition-opacity ease-out",
position === "fixed" ? "fixed" : "absolute",
visible ? "opacity-100 duration-150" : "opacity-0 duration-300",
className,
)}
style={style}
{...rest}
>
<Progress.Indicator
className={cn(
"absolute inset-y-0 left-0 transition-[transform,background-color] ease-out-quart motion-reduce:duration-0",
moving,
error && phase !== "trickling" ? "bg-danger" : "bg-fg",
)}
// Full width, slid left: composited, and the head stays crisp.
style={{ width: "100%", transform: `translateX(${(p - 1) * 100}%)` }}
/>
</Progress.Root>
);
}05Props
TopLoader
| Prop | Type | Default | Description |
|---|---|---|---|
| loading* | boolean | — | True while loading. Turning it false runs the bar to the end and fades it. |
| error | boolean | false | Finish in the danger color and hold it a little longer, for a load that failed. |
| delay | number | 120 | Milliseconds before the bar appears. Loads that finish sooner never show it. |
| position | "fixed" | "absolute" | "fixed" | Pinned to the viewport's top edge, or to the top of the nearest positioned parent. |
| label | string | "Loading page" | Accessible name while it is visible. |
useTopLoader
| Prop | Type | Default | Description |
|---|---|---|---|
| start | () => void | — | Count one more load in progress. Clears any previous error. |
| done | () => void | — | Count one load finished. The bar completes when the count reaches zero. |
| fail | () => void | — | Like done, but the bar finishes in the danger color. |
| track | <T>(promise: Promise<T>) => Promise<T> | — | start() now, then done() or fail() when the promise settles. Returns the same promise. |
| loading / error | boolean | — | Pass these straight to TopLoader. |
06Notes
Behavior
- Nothing shows for the first 120ms, so quick navigations don't flash a bar.
- The trickle covers a share of the remaining distance every 480ms, slowing towards 94% and never finishing on its own; it pauses while the tab is hidden.
- Starting again while the last bar is fading resets it to the left without animating backwards, then trickles from the start.
- The hook counts starts and finishes, so parallel requests share one bar that completes only when the last one settles.
Motion
- Trickle steps ease over 600ms (ease-out-quart) so the bar creeps rather than jumps. On finish it runs to 100% in 200ms, holds 240ms (520ms on error), then fades over 300ms.
- The fill is a full-width bar slid left with transform, so every frame is composited and the leading edge stays crisp.
- Reduced motion drops the easing between steps; the bar still appears, advances and disappears so the information arrives.
Accessibility
- A progressbar named "Loading page" with its current value while visible; aria-hidden when idle, so it isn't announced between loads.
- It doesn't take focus or intercept pointer events. Announce the result of a failed load in the page itself, as the demo's alert does.