Staged progress bar for React and Tailwind CSS (shadcn/ui) that splits one progress bar into named, weighted stages such as Build, Test, Upload and Verify, and rides the current stage label on the fill head as it advances. It is built to tell people which step a job is in and what comes next, not just a percentage, and to end with a clear done pill instead of a full bar that just sits there. A staged progress bar is a determinate progress indicator whose track is divided into labeled segments; this one takes stages with ids, labels and weights and a value from 0 to 100, animates the fill and the head with the Web Animations API, rolls the label at each stage boundary, colors completed stages with the shadcn chart accent, turns a failed stage destructive with a Retry pill and onRetry, fires onComplete, optionally collapses into a Done pill, falls back to an indeterminate shimmer when the value is unknown, exposes role progressbar with aria-valuetext per stage, and respects reduced motion. Use it for uploads, deployments, imports, AI generation jobs and onboarding checklists. The included demo runs a deploy, a failed import with retry, and a queued job and makes no requests. Requires React and lucide-react, with no animation library.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Staged Progress Bar to your project and fits it to your theme.
Code
How Staged Progress Bar is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client";
import * as React from "react";
import { RotateCcw } from "lucide-react";
import { StagedProgressBar } from "@/components/ui/staged-progress-bar";
import type { ProgressStage } from "@/components/ui/staged-progress-bar";
type DemoProps = { autoplay?: boolean; bare?: boolean; value?: number; collapse?: boolean; speed?: number };
const DEPLOY: ProgressStage[] = [{ id: "build", label: "Build", weight: 2 }, { id: "test", label: "Test", weight: 2 }, { id: "upload", label: "Upload" }, { id: "verify", label: "Verify" }];
const IMPORT: ProgressStage[] = [{ id: "upload", label: "Upload" }, { id: "scan", label: "Scan" }, { id: "import", label: "Import", weight: 2 }];
export default function StagedProgressBarDemo({ autoplay = true, bare = false, value: preset, collapse = true, speed = 1 }: DemoProps) {
const [deploy, setDeploy] = React.useState(preset ?? 0);
// During the tour the import fails live at the Scan stage; with autoplay off it starts already failed so Retry can be tried by hand.
const live = autoplay && preset === undefined;
const [imported, setImported] = React.useState(live ? 0 : 42);
const [importStatus, setImportStatus] = React.useState((live ? "running" : "error") as "error" | "running");
const failed = React.useRef(!live);
const [events, setEvents] = React.useState([] as string[]);
const [motion, setMotion] = React.useState({ ready: false, reduced: true });
const [run, setRun] = React.useState(0);
const [paused, setPaused] = React.useState(false);
const retryRef = React.useRef(null as HTMLDivElement | null);
const note = (text: string) => setEvents((previous) => [...previous, text]);
const ms = (at: number) => Math.round(at * speed);
React.useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setMotion({ ready: true, reduced: media.matches });
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
React.useEffect(() => {
if (!motion.ready || !autoplay || motion.reduced || paused || preset !== undefined) return;
const timers: number[] = [];
let frame = 0;
const start = performance.now() + ms(300);
const tick = (now: number) => {
const t = Math.min(1, (now - start) / ms(5300));
if (t >= 0) setDeploy(Math.round(t * 1000) / 10);
if (t < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
timers.push(window.setTimeout(() => (retryRef.current?.querySelector("[data-retry]") as HTMLButtonElement | null)?.click(), ms(3000)));
timers.push(window.setTimeout(() => { setDeploy(0); failed.current = false; setImported(0); setImportStatus("running"); setEvents([]); setRun((previous) => previous + 1); }, ms(9600)));
return () => { cancelAnimationFrame(frame); timers.forEach((timer) => window.clearTimeout(timer)); };
}, [motion.ready, motion.reduced, autoplay, paused, run, preset, speed]);
React.useEffect(() => {
if (importStatus !== "running" || imported >= 100) return;
let frame = 0;
const from = imported; const start = performance.now();
const firstPass = !failed.current;
const target = firstPass ? 42 : 100;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / ms(firstPass ? 1300 : 2500));
setImported(Math.round((from + (target - from) * t) * 10) / 10);
if (t < 1) frame = requestAnimationFrame(tick);
else if (firstPass) { failed.current = true; setImportStatus("error"); }
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [importStatus, run]);
const takeOver = () => autoplay && setPaused(true);
return (
<main className="flex min-h-svh w-full flex-col items-center justify-center gap-5 bg-background px-5 py-6 text-foreground" onPointerDownCapture={takeOver} onKeyDownCapture={takeOver}>
{!bare && (
<header className="w-full max-w-[480px]">
<p className="mb-1 text-[10px] font-medium uppercase tracking-[0.2em] text-muted-foreground">Not just a percentage</p>
<h1 className="text-[24px] font-semibold leading-tight tracking-tight">Which step, what's next, then done.</h1>
</header>
)}
<section className="w-full max-w-[480px] space-y-6 rounded-3xl border border-border bg-card p-5 shadow-xl shadow-primary/5">
<div data-job="deploy">
<div className="mb-2 flex items-baseline justify-between"><p className="text-sm font-medium">Deploy v2.4.0</p><span className="font-mono text-[11px] text-muted-foreground">production</span></div>
<StagedProgressBar stages={DEPLOY} value={deploy} collapseOnComplete={collapse} label="Deploy progress" onComplete={() => note("deploy:complete")} />
{!autoplay && <input type="range" data-scrub aria-label="Deploy progress" min={0} max={100} value={deploy} onChange={(event) => setDeploy(Number(event.target.value))} className="mt-3 h-1.5 w-full cursor-pointer accent-chart-3" />}
</div>
<div data-job="import" ref={retryRef}>
<div className="mb-2 flex items-baseline justify-between"><p className="text-sm font-medium">Import contacts.csv</p><span className="font-mono text-[11px] text-muted-foreground">2,418 rows</span></div>
<StagedProgressBar stages={IMPORT} value={imported} status={importStatus === "error" ? "error" : imported >= 100 ? "done" : "running"} errorStageId="scan" collapseOnComplete={collapse} label="Import progress" retryLabel="Retry scan" onRetry={(stage) => { note(`retry:${stage}`); setImportStatus("running"); }} onComplete={() => note("import:complete")} />
</div>
<div data-job="agent">
<div className="mb-2 flex items-baseline justify-between"><p className="text-sm font-medium">Waiting for a build agent</p><span className="font-mono text-[11px] text-muted-foreground">queued</span></div>
<StagedProgressBar stages={[{ id: "queue", label: "Queue" }, { id: "assign", label: "Assign" }]} size="sm" label="Agent queue" />
</div>
<p data-events className="sr-only">{events.join(",")}</p>
</section>
{!bare && (
<footer className="flex w-full max-w-[480px] items-center justify-between text-[10px] text-muted-foreground">
<span>Sample jobs · nothing is deployed</span>
<button type="button" data-replay onClick={() => { setDeploy(0); failed.current = !live; setImported(live ? 0 : 42); setImportStatus(live ? "running" : "error"); setEvents([]); setPaused(false); setRun((previous) => previous + 1); }} className="inline-flex items-center gap-1 rounded px-1 underline decoration-border underline-offset-4 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><RotateCcw className="size-3" aria-hidden="true" />Replay</button>
</footer>
)}
</main>
);
}








