Progress
Displays the status of a task that takes a long time. Built with Base UI and Tailwind CSS. Copy-paste ready.
"use client";
import { useEffect, useState } from "react";
import {
Progress,
ProgressIndicator,
ProgressLabel,
ProgressTrack,
ProgressValue,
} from "@/components/ui/progress";
export default function Particle() {
const [_value, setValue] = useState(20);
useEffect(() => {
const interval = setInterval(() => {
setValue((current) =>
Math.min(100, Math.round(current + Math.random() * 25)),
);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<Progress value={60}>
<div className="flex items-center justify-between gap-2">
<ProgressLabel>Export data</ProgressLabel>
<ProgressValue />
</div>
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</Progress>
);
}
Installation
pnpm dlx shadcn@latest add @cnippet/progress
Usage
import {
Progress,
ProgressLabel,
ProgressValue,
} from "@/components/ui/progress"<Progress value={40} />Note: If you render children inside Progress, you must also include ProgressTrack and ProgressIndicator inside it. Without them, the bar will not display. When no children are provided, a default track and indicator are rendered for you.
Examples
With Formatted Value
Displays the numeric percentage alongside the bar using ProgressValue with a custom formatter.
"use client";
import {
Progress,
ProgressIndicator,
ProgressLabel,
ProgressTrack,
ProgressValue,
} from "@/components/ui/progress";
export default function Particle() {
return (
<Progress max={512} value={502}>
<div className="flex items-center justify-between gap-2">
<ProgressLabel>Upload</ProgressLabel>
<ProgressValue>{(_formatted, value) => `${value} / 512`}</ProgressValue>
</div>
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</Progress>
);
}
With Status
Shows a dynamic status label (e.g. "Uploading…", "Complete") that updates as the value crosses defined thresholds.
"use client";
import { useEffect, useState } from "react";
import {
Progress,
ProgressIndicator,
ProgressLabel,
ProgressTrack,
ProgressValue,
} from "@/components/ui/progress";
export function BaseProgressStatus() {
const [downloadProgress, setDownloadProgress] = useState(0);
// Get status message based on progress
const getStatusMessage = (progress: number) => {
if (progress < 5) return "Initializing download...";
if (progress < 15) return "Setting up environment...";
if (progress < 25) return "Connecting to server...";
if (progress < 35) return "Verifying permissions...";
if (progress < 50) return "Downloading core files...";
if (progress < 65) return "Downloading assets...";
if (progress < 80) return "Downloading dependencies...";
if (progress < 90) return "Extracting files...";
if (progress < 95) return "Validating integrity...";
if (progress < 100) return "Finalizing installation...";
return "Download complete!";
};
useEffect(() => {
// Download simulation
const downloadTimer = setInterval(() => {
setDownloadProgress((prev) => {
if (prev >= 100) {
return 0; // Reset for continuous loop
}
return prev + Math.random() * 3 + 1; // Random increment 1-4
});
}, 150);
return () => {
clearInterval(downloadTimer);
};
}, []);
return (
<div className="w-full max-w-xs space-y-2">
<Progress value={downloadProgress}>
<ProgressLabel>Workspace Setup</ProgressLabel>
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
<ProgressValue />
</Progress>
<div className="text-muted-foreground text-xs">
{getStatusMessage(downloadProgress)}
</div>
</div>
);
}
With slider
Pairs a Progress bar with a Slider so users can manually scrub the value and see both components respond simultaneously.
"use client";
import { useState } from "react";
import { Progress } from "@/components/ui/progress";
import { Slider } from "@/components/ui/slider";
export function Pattern() {
const [value, setValue] = useState(50);
return (
<div className="mx-auto flex w-full max-w-xs flex-col gap-6">
<Progress value={value} />
<Slider
max={100}
min={0}
onValueChange={(value: number | readonly number[]) =>
setValue(value as number)
}
step={1}
value={[value]}
/>
</div>
);
}
Multi-step
Renders a segmented step indicator with labeled stages — each segment fills as the user advances through a multi-step flow.
import { CircleCheckIcon, CircleIcon } from "lucide-react";
import { Progress } from "@/components/ui/progress";
const steps = [
{ completed: true, label: "Account" },
{ completed: true, label: "Profile" },
{ completed: false, label: "Preferences" },
{ completed: false, label: "Review" },
];
export function Pattern() {
const completedSteps = steps.filter((s) => s.completed).length;
const progressValue = (completedSteps / steps.length) * 100;
return (
<div className="mx-auto w-full max-w-xs space-y-4">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Setup Progress</span>
<span className="text-muted-foreground text-xs">
{completedSteps} of {steps.length} steps
</span>
</div>
<Progress value={progressValue} />
<div className="flex flex-col gap-2">
{steps.map((step) => (
<div className="flex items-center gap-2 text-sm" key={step.label}>
{step.completed ? (
<CircleCheckIcon
aria-hidden="true"
className="size-4 text-success"
/>
) : (
<CircleIcon
aria-hidden="true"
className="size-4 text-muted-foreground"
/>
)}
<span
className={
step.completed ? "text-foreground" : "text-muted-foreground"
}
>
{step.label}
</span>
</div>
))}
</div>
</div>
);
}
Custom Colors
Overrides the indicator color based on the current value — green for healthy, amber for warning, red for critical.
"use client";
import { useEffect, useState } from "react";
import { Progress } from "@/components/ui/progress";
export function Pattern() {
const [progress, setProgress] = useState(45);
useEffect(() => {
const timer = setTimeout(() => setProgress(75), 500);
return () => clearTimeout(timer);
}, []);
return (
<div className="mx-auto flex w-full max-w-md flex-col gap-6">
<div className="flex items-center justify-between gap-2">
Custom Colors
</div>
<Progress
className="**:data-[slot=progress-indicator]:bg-green-500"
value={progress}
/>
<Progress
className="**:data-[slot=progress-indicator]:bg-yellow-500"
value={progress}
/>
<Progress
className="**:data-[slot=progress-indicator]:bg-fuchsia-500"
value={progress}
/>
<Progress
className="**:data-[slot=progress-indicator]:bg-indigo-500"
value={progress}
/>
<Progress
className="**:data-[slot=progress-indicator]:bg-violet-500"
value={progress}
/>
</div>
);
}
Skills Meter
Six technology skills each displayed with a labeled progress bar and a percentage readout — a static competency or proficiency display.
Skills
import {
Progress,
ProgressIndicator,
ProgressTrack,
} from "@/components/ui/progress";
const skills = [
{ label: "TypeScript", value: 92 },
{ label: "React", value: 88 },
{ label: "Node.js", value: 74 },
{ label: "PostgreSQL", value: 65 },
{ label: "Docker", value: 58 },
{ label: "Rust", value: 31 },
];
export function Pattern() {
return (
<div className="w-full max-w-xs space-y-3">
<p className="font-semibold text-sm">Skills</p>
{skills.map((skill) => (
<Progress key={skill.label} value={skill.value}>
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-foreground">{skill.label}</span>
<span className="text-muted-foreground tabular-nums">
{skill.value}%
</span>
</div>
<ProgressTrack className="h-1.5">
<ProgressIndicator />
</ProgressTrack>
</Progress>
))}
</div>
);
}
File Upload Queue
Four files with live simulated upload progress. Each row shows a file icon, status icon (spinner / check / error), and a progress bar whose color and icon change based on upload state.
Uploading files
1 / 4 done"use client";
import {
CheckCircle2Icon,
FileIcon,
Loader2Icon,
XCircleIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
Progress,
ProgressIndicator,
ProgressTrack,
} from "@/components/ui/progress";
type FileStatus = "uploading" | "done" | "error";
type UploadFile = {
id: string;
name: string;
size: string;
status: FileStatus;
progress: number;
};
const initialFiles: UploadFile[] = [
{
id: "1",
name: "design-system.fig",
progress: 100,
size: "4.2 MB",
status: "done",
},
{
id: "2",
name: "brand-assets.zip",
progress: 67,
size: "12.8 MB",
status: "uploading",
},
{
id: "3",
name: "prototype-v3.mp4",
progress: 23,
size: "38.5 MB",
status: "uploading",
},
{
id: "4",
name: "corrupted-file.pdf",
progress: 41,
size: "1.1 MB",
status: "error",
},
];
const statusIcon: Record<FileStatus, React.ReactNode> = {
done: <CheckCircle2Icon className="size-4 text-success" />,
error: <XCircleIcon className="size-4 text-destructive" />,
uploading: (
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
),
};
const indicatorColor: Record<FileStatus, string> = {
done: "**:data-[slot=progress-indicator]:bg-success",
error: "**:data-[slot=progress-indicator]:bg-destructive",
uploading: "",
};
export function Pattern() {
const [files, setFiles] = useState<UploadFile[]>(initialFiles);
useEffect(() => {
const timer = setInterval(() => {
setFiles((prev) =>
prev.map((f) => {
if (f.status !== "uploading") return f;
const next = Math.min(f.progress + Math.random() * 8, 100);
return {
...f,
progress: next,
status: next >= 100 ? "done" : "uploading",
};
}),
);
}, 400);
return () => clearInterval(timer);
}, []);
return (
<div className="w-full max-w-sm space-y-4">
<div className="flex items-center justify-between">
<p className="font-semibold text-sm">Uploading files</p>
<span className="text-muted-foreground text-xs">
{files.filter((f) => f.status === "done").length} / {files.length}{" "}
done
</span>
</div>
{files.map((file) => (
<div className="flex items-start gap-3" key={file.id}>
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border bg-muted/50">
<FileIcon className="size-3.5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-medium text-sm">{file.name}</span>
{statusIcon[file.status]}
</div>
<Progress
className={indicatorColor[file.status]}
value={Math.round(file.progress)}
>
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</Progress>
<div className="flex items-center justify-between text-muted-foreground text-xs">
<span>{file.size}</span>
<span className="tabular-nums">
{file.status === "error"
? "Failed"
: `${Math.round(file.progress)}%`}
</span>
</div>
</div>
</div>
))}
</div>
);
}
Budget Breakdown
A monthly budget card with six spending categories. When a category exceeds its limit the indicator flips to destructive red and a warning line appears below the bar.
$21 over limit
ximport {
Card,
CardDescription,
CardHeader,
CardPanel,
CardTitle,
} from "@/components/ui/card";
import {
Progress,
ProgressIndicator,
ProgressTrack,
} from "@/components/ui/progress";
const categories = [
{
budget: 600,
color: "**:data-[slot=progress-indicator]:bg-blue-500",
label: "Housing",
spent: 600,
},
{
budget: 400,
color: "**:data-[slot=progress-indicator]:bg-emerald-500",
label: "Groceries",
spent: 284,
},
{
budget: 150,
color: "**:data-[slot=progress-indicator]:bg-violet-500",
label: "Transport",
spent: 137,
},
{
budget: 200,
color: "**:data-[slot=progress-indicator]:bg-amber-500",
label: "Dining out",
spent: 198,
},
{
budget: 100,
color: "**:data-[slot=progress-indicator]:bg-rose-500",
label: "Entertainment",
spent: 121,
},
{
budget: 80,
color: "**:data-[slot=progress-indicator]:bg-cyan-500",
label: "Health",
spent: 42,
},
];
const totalBudget = categories.reduce((s, c) => s + c.budget, 0);
const totalSpent = categories.reduce((s, c) => s + c.spent, 0);
const remaining = totalBudget - totalSpent;
export function Pattern() {
return (
<div className="w-full max-w-sm">
<Card>
<CardHeader className="border-b">
<CardTitle>May Budget</CardTitle>
<CardDescription>
<span
className={remaining < 0 ? "font-medium text-destructive" : ""}
>
${Math.abs(remaining).toFixed(0)}{" "}
{remaining < 0 ? "over budget" : "remaining"}
</span>
{" · "}${totalSpent} of ${totalBudget} spent
</CardDescription>
</CardHeader>
<CardPanel className="space-y-4">
{categories.map((cat) => {
const _pct = Math.min((cat.spent / cat.budget) * 100, 100);
const over = cat.spent > cat.budget;
return (
<Progress
className={cat.color}
key={cat.label}
max={cat.budget}
value={cat.spent}
>
<div className="flex items-center justify-between text-xs">
<span className="font-medium">{cat.label}</span>
<span
className={`tabular-nums ${over ? "font-medium text-destructive" : "text-muted-foreground"}`}
>
${cat.spent}
<span className="font-normal text-muted-foreground">
{" "}
/ ${cat.budget}
</span>
</span>
</div>
<ProgressTrack className="h-2">
<ProgressIndicator
className={over ? "bg-destructive!" : undefined}
/>
</ProgressTrack>
{over && (
<p className="text-destructive text-xs">
${cat.spent - cat.budget} over limit
</p>
)}
</Progress>
);
})}
</CardPanel>
</Card>
</div>
);
}
API Usage Tiers
A thick progress bar with floating threshold markers at Free, Pro, and Team limits. A callout below the bar shows how many requests remain until the next tier is reached.
import { ZapIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardDescription,
CardHeader,
CardPanel,
CardTitle,
} from "@/components/ui/card";
import {
Progress,
ProgressIndicator,
ProgressTrack,
} from "@/components/ui/progress";
const USED = 8_340;
const LIMIT = 50_000;
const tiers = [
{ label: "Free", threshold: 5_000 },
{ label: "Pro", threshold: 20_000 },
{ label: "Team", threshold: 50_000 },
];
function fmt(n: number) {
return n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n);
}
export function Pattern() {
const pct = (USED / LIMIT) * 100;
const currentTier = tiers.reduce((acc, tier) =>
USED > acc.threshold ? tier : acc,
);
const nextTier = tiers.find((t) => t.threshold > USED);
const toNext = nextTier ? nextTier.threshold - USED : 0;
return (
<div className="w-full max-w-sm">
<Card>
<CardHeader className="border-b">
<div className="flex items-center justify-between gap-2">
<CardTitle>API Usage</CardTitle>
<Badge size="sm" variant="info">
<ZapIcon className="size-3" />
{currentTier.label}
</Badge>
</div>
<CardDescription>
{USED.toLocaleString()} of {LIMIT.toLocaleString()} requests this
month
</CardDescription>
</CardHeader>
<CardPanel className="space-y-5">
<Progress value={pct}>
<ProgressTrack className="relative h-3 rounded-full">
<ProgressIndicator className="rounded-full bg-primary transition-all duration-700" />
{tiers.slice(0, -1).map((tier) => {
const markerPct = (tier.threshold / LIMIT) * 100;
const passed = USED >= tier.threshold;
return (
<span
className={`absolute top-0 h-full w-px ${passed ? "bg-primary-foreground/50" : "bg-border"}`}
key={tier.label}
style={{ left: `${markerPct}%` }}
/>
);
})}
</ProgressTrack>
</Progress>
<div className="relative flex justify-between text-xs">
<span className="text-muted-foreground">0</span>
{tiers.map((tier) => {
const markerPct = (tier.threshold / LIMIT) * 100;
const active = currentTier.label === tier.label;
return (
<span
className={`absolute -translate-x-1/2 ${active ? "font-semibold text-foreground" : "text-muted-foreground"}`}
key={tier.label}
style={{ left: `${markerPct}%` }}
>
{fmt(tier.threshold)}
</span>
);
})}
</div>
{nextTier && (
<div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-xs">
<span className="font-medium">
{toNext.toLocaleString()} requests
</span>
<span className="text-muted-foreground">
{" "}
until you reach the{" "}
<span className="font-medium text-foreground">
{nextTier.label}
</span>{" "}
tier limit
</span>
</div>
)}
</CardPanel>
</Card>
</div>
);
}
Survey Completion
A labeled bar with a "4 of 10" counter replacing the percentage — the natural format for multi-question surveys and quizzes.
"use client";
import {
Progress,
ProgressIndicator,
ProgressLabel,
ProgressTrack,
ProgressValue,
} from "@/components/ui/progress";
const TOTAL = 10;
const CURRENT = 4;
export function Pattern() {
const value = (CURRENT / TOTAL) * 100;
return (
<div className="w-full max-w-sm">
<Progress value={value}>
<div className="flex items-center justify-between gap-2">
<ProgressLabel>Survey progress</ProgressLabel>
<ProgressValue>{() => `${CURRENT} of ${TOTAL}`}</ProgressValue>
</div>
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</Progress>
</div>
);
}
Download with Speed
A simulated download bar with a filename header and an animated MB/s + "~Xs remaining" readout beneath, driven by setInterval.
"use client";
import { DownloadIcon } from "lucide-react";
import { useEffect, useState } from "react";
import {
Progress,
ProgressIndicator,
ProgressTrack,
} from "@/components/ui/progress";
export function Pattern() {
const [progress, setProgress] = useState(34);
useEffect(() => {
const id = setInterval(() => {
setProgress((p) => {
if (p >= 100) return 0;
return Math.min(p + Math.random() * 4, 100);
});
}, 300);
return () => clearInterval(id);
}, []);
const pct = Math.round(progress);
const remaining = Math.max(0, Math.ceil(((100 - progress) / 100) * 38));
return (
<div className="w-full max-w-xs space-y-3">
<div className="flex items-center gap-2">
<DownloadIcon
aria-hidden="true"
className="size-4 text-muted-foreground"
/>
<span className="font-medium text-sm">Downloading update…</span>
<span className="ml-auto text-muted-foreground text-xs tabular-nums">
{pct}%
</span>
</div>
<Progress value={progress}>
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</Progress>
<div className="flex justify-between text-muted-foreground text-xs tabular-nums">
<span>2.4 MB/s</span>
<span>
{remaining > 0 ? `~${remaining}s remaining` : "Finalizing…"}
</span>
</div>
</div>
);
}
Fundraiser Goal
A fundraising card with a red-tinted bar and raised/goal currency breakdown — indicator color overridden with className on ProgressIndicator.
$8,340
raised of $15,000 goal
56%
import { HeartIcon } from "lucide-react";
import {
Progress,
ProgressIndicator,
ProgressTrack,
} from "@/components/ui/progress";
const RAISED = 8_340;
const GOAL = 15_000;
export function Pattern() {
const pct = Math.min((RAISED / GOAL) * 100, 100);
return (
<div className="w-full max-w-xs space-y-3 rounded-lg border p-4">
<div className="flex items-center gap-2">
<HeartIcon aria-hidden="true" className="size-4 text-red-500" />
<span className="font-semibold text-sm">Community Fund Drive</span>
</div>
<Progress value={pct}>
<ProgressTrack>
<ProgressIndicator className="bg-red-500" />
</ProgressTrack>
</Progress>
<div className="flex items-end justify-between gap-2">
<div>
<p className="font-bold text-lg">${RAISED.toLocaleString()}</p>
<p className="text-muted-foreground text-xs">
raised of ${GOAL.toLocaleString()} goal
</p>
</div>
<p className="font-medium text-muted-foreground text-sm">
{Math.round(pct)}%
</p>
</div>
</div>
);
}
Reading Progress
A 1px-tall bar pinned to the top of a scrollable article container that tracks scroll position via a useEffect scroll listener.
Reading Progress
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
"use client";
import { useEffect, useState } from "react";
import {
Progress,
ProgressIndicator,
ProgressTrack,
} from "@/components/ui/progress";
export function Pattern() {
const [scroll, setScroll] = useState(0);
useEffect(() => {
const el = document.getElementById("scroll-article");
function onScroll() {
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
const total = scrollHeight - clientHeight;
setScroll(total > 0 ? (scrollTop / total) * 100 : 0);
}
el?.addEventListener("scroll", onScroll);
return () => el?.removeEventListener("scroll", onScroll);
}, []);
return (
<div className="w-full max-w-xs overflow-hidden rounded-lg border">
<Progress value={scroll}>
<ProgressTrack className="h-1 rounded-none">
<ProgressIndicator className="transition-none" />
</ProgressTrack>
</Progress>
<div
className="h-44 overflow-y-auto p-4 text-muted-foreground text-sm"
id="scroll-article"
>
<p className="mb-2 font-semibold text-foreground">Reading Progress</p>
{Array.from({ length: 8 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static lorem paragraphs
<p className="mb-3 leading-relaxed" key={i}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
ad minim veniam, quis nostrud exercitation ullamco laboris.
</p>
))}
</div>
</div>
);
}
Build Pipeline
A progress bar paired with a per-stage checklist — CheckIcon for done, an animated CircleDotIcon for the active stage, and ClockIcon for pending ones.
import { CheckIcon, CircleDotIcon, ClockIcon } from "lucide-react";
import {
Progress,
ProgressIndicator,
ProgressTrack,
} from "@/components/ui/progress";
type StageStatus = "done" | "active" | "pending";
const stages: { label: string; status: StageStatus; time: string }[] = [
{ label: "Checkout", status: "done", time: "3s" },
{ label: "Install", status: "done", time: "42s" },
{ label: "Build", status: "active", time: "1m 12s" },
{ label: "Test", status: "pending", time: "—" },
{ label: "Deploy", status: "pending", time: "—" },
];
const done = stages.filter((s) => s.status === "done").length;
const stageIcon: Record<StageStatus, React.ReactNode> = {
active: (
<CircleDotIcon
aria-hidden="true"
className="size-4 animate-pulse text-primary"
/>
),
done: <CheckIcon aria-hidden="true" className="size-4 text-success" />,
pending: (
<ClockIcon aria-hidden="true" className="size-4 text-muted-foreground" />
),
};
export function Pattern() {
const value = (done / stages.length) * 100;
return (
<div className="w-full max-w-xs space-y-4">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Build pipeline</span>
<span className="text-muted-foreground text-xs">
{done}/{stages.length} stages
</span>
</div>
<Progress value={value}>
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</Progress>
<div className="space-y-2">
{stages.map(({ label, status, time }) => (
<div className="flex items-center gap-3" key={label}>
{stageIcon[status]}
<span
className={`flex-1 text-sm ${status === "pending" ? "text-muted-foreground" : ""}`}
>
{label}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
{time}
</span>
</div>
))}
</div>
</div>
);
}

