Progress
Linear progress bar — determinate (0–100) or indeterminate loop.
import { Progress }from "@/components/ui"
Specification
Shows a linear progress bar — determinate for a known 0–100 value, or an indeterminate loop when the value is null.
Props (5)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | null | — | Completion percentage (0–100); pass null or omit for an indeterminate loop. |
| tone | "accent" | "warning" | "error" | "accent" | Colors the progress fill. |
| showValue | boolean | — | Displays the rounded percentage beside the label (determinate only). |
| label | string | — | Text label shown above the bar and used as its aria-label. |
| className | string | — | Extra classes merged onto the wrapper. |
Determinate & indeterminate
Upload68%
Processing…
Full source
The complete component, read from
src/components/ui/progress.tsx.progress.tsx
"use client";
import { cn } from "@/lib/utils";
type ProgressTone = "accent" | "warning" | "error";
const TONE: Record<ProgressTone, string> = {
accent: "bg-accent",
warning: "bg-warning",
error: "bg-error",
};
interface ProgressProps {
value?: number | null;
tone?: ProgressTone;
showValue?: boolean;
label?: string;
className?: string;
}
export function Progress({ value, tone = "accent", showValue, label, className }: ProgressProps) {
const indeterminate = value === null || value === undefined;
const pct = indeterminate ? 0 : Math.max(0, Math.min(100, value));
return (
<div className={cn("w-full", className)}>
{(label || showValue) && (
<div className="mb-1.5 flex items-center justify-between text-xs">
{label && <span className="font-medium text-text">{label}</span>}
{showValue && !indeterminate && <span className="tabular-nums text-text-secondary">{Math.round(pct)}%</span>}
</div>
)}
<div
role="progressbar"
aria-valuenow={indeterminate ? undefined : Math.round(pct)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label ?? "Progress"}
className="h-2 w-full overflow-hidden rounded-full bg-bg-active"
>
{indeterminate ? (
<div className={cn("h-full w-1/3 rounded-full", TONE[tone], "animate-[progress-indeterminate_1.2s_ease-in-out_infinite]")} />
) : (
<div
className={cn("h-full rounded-full transition-[width] duration-300", TONE[tone])}
style={{ width: `${pct}%` }}
/>
)}
</div>
</div>
);
}Dependencies
To use Progress you also need the following. Copy these into your project alongside it.
Recommended folder structure
project structure
src/components/ui/ └─ progress.tsx src/lib/ └─ utils.ts
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge class names with clsx semantics + Tailwind conflict resolution.
* Prefer this in all components.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Concatenate class names, filtering out falsy values.
* @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
*/
export function cx(...args: (string | false | null | undefined)[]): string {
return args.filter(Boolean).join(" ");
}
/**
* Focus ring style helper.
* @deprecated Focus rings are now handled globally via the
* `:focus-visible` rule in `globals.css` and the `focus-visible:*`
* utilities. This remains only for backwards compatibility.
*/
export function ring(color: string = "var(--color-accent)") {
return { outlineColor: color };
}