Progress
A pure UI progress bar that only renders current and duration — no built-in scheduling, sampling, or estimation logic
Installation
Usage
Basic Usage
Progress is a pure UI component: the bar width transitions to current (%) over duration (ms) via CSS — progress computation, sampling, estimation, and polling all belong to the business side, and the component ships no timers of its own. isAbort and isComplete express the failure and success semantics: aborting freezes the bar at its current width instantly with the failure color, while completing forces 100% with the success color.
Idle
"use client";
import { useRef, useState } from "react";
import { Button, Progress } from "@/ui";
type Phase = "idle" | "running" | "complete" | "aborted";
export default function ProgressBasicDemo() {
const [phase, setPhase] = useState<Phase>("idle");
const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0);
const [realProgress, setRealProgress] = useState(0);
const taskRef = useRef({ progress: 0, startedAt: 0 });
const simulateRef = useRef<number | null>(null);
const pollRef = useRef<number | null>(null);
const timersRef = useRef<number[]>([]);
const clearSchedule = () => {
if (simulateRef.current !== null) {
window.clearInterval(simulateRef.current);
simulateRef.current = null;
}
if (pollRef.current !== null) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
timersRef.current.forEach((id) => window.clearTimeout(id));
timersRef.current = [];
};
const complete = () => {
clearSchedule();
setPhase("complete");
};
const sample = () => {
const elapsed = performance.now() - taskRef.current.startedAt;
const progress = taskRef.current.progress;
return { progress, elapsed, rate: progress / elapsed };
};
const start = () => {
clearSchedule();
taskRef.current = { progress: 0, startedAt: performance.now() };
setCurrent(0);
setDuration(0);
setRealProgress(0);
setPhase("running");
simulateRef.current = window.setInterval(() => {
taskRef.current.progress = Math.min(
1,
taskRef.current.progress + Math.random() * 0.02 + 0.005,
);
}, 100);
const firstSampleTimer = window.setTimeout(() => {
const { progress, elapsed, rate } = sample();
if (progress >= 1) {
complete();
return;
}
const estimate = elapsed + (1 - progress) / rate;
setCurrent(99.999);
setDuration(estimate);
[0.25, 0.5, 0.75].forEach((point) => {
const id = window.setTimeout(() => {
const next = sample();
if (next.progress >= 1) {
complete();
return;
}
setRealProgress(next.progress);
setDuration((1 - next.progress) / (next.progress / next.elapsed));
}, estimate * point);
timersRef.current.push(id);
});
const estimateTimer = window.setTimeout(() => {
pollRef.current = window.setInterval(() => {
setRealProgress(taskRef.current.progress);
if (taskRef.current.progress >= 1) complete();
}, 200);
}, estimate);
timersRef.current.push(estimateTimer);
}, 1000);
timersRef.current.push(firstSampleTimer);
};
const abort = () => {
clearSchedule();
setPhase("aborted");
};
const reset = () => {
clearSchedule();
setPhase("idle");
setCurrent(0);
setDuration(0);
setRealProgress(0);
};
return (
<div className="flex w-72 max-w-full flex-col gap-3">
<Progress
current={current}
duration={duration}
isAbort={phase === "aborted"}
isComplete={phase === "complete"}
/>
<p className="text-sm text-muted-foreground">
{phase === "idle" && "Idle"}
{phase === "running" &&
`Running — UI target 99.999% / ${Math.round(duration / 100) / 10}s, backend ${Math.round(realProgress * 100)}%`}
{phase === "complete" && "Complete"}
{phase === "aborted" && "Aborted"}
</p>
<div className="flex gap-2">
{phase === "running" ? (
<Button className={[Button.className.base, Button.className.variant.danger]} onClick={abort}>
Abort
</Button>
) : (
<Button
className={[Button.className.base, Button.className.variant.primary]}
disabled={phase === "complete"}
onClick={start}
>
Start
</Button>
)}
<Button className={[Button.className.base, Button.className.variant.outline]} onClick={reset}>
Reset
</Button>
</div>
</div>
);
}
Key points:
- The bar width transitions to
current% viatransition-[width]overdurationms — changingdurationwhilecurrentstays the same does not restart an in-flight transition; that is inherent CSS transition behavior. - When
isAbortis true the transition duration drops to zero, the width freezes at the pre-abort position (the component records the last non-aborted width internally), subsequentcurrent/durationare ignored, and the fill switches tobg-danger. - When
isCompleteis true the width is forced to 100% and the fill switches tobg-success. - The demo shows the typical async-task driving pattern: sample once 1s after start to compute the rate and the estimated total duration, set
current=99.999withduration=estimate; resample at 1/4, 1/2, 3/4 of the estimate and refineduration; after the estimate elapses, poll the simulated task and only setisCompletewhen it truly finishes. Deviation between the UI progress and the real backend progress is expected — that is the trade of smoothness for estimation.
API Reference
Progress
| Prop | Type | Default | Description |
|---|---|---|---|
current | number | - | Target width percentage (0–100, clamped automatically) |
duration | number | - | Width transition duration in milliseconds |
isAbort | boolean | false | Abort: stop the transition instantly, freeze the width, apply the failure color, ignore further progress |
isComplete | boolean | false | Complete: force the width to 100% and apply the success color |
className | ClassNameValue | - | Custom class names, applied to the track |
Query Builder
A tree list of field rows with always-visible operator/value rules, nested and/or groups, a live natural-language preview and Submit/Reset — zero external dependencies.
Radio
A parts-based radio component with an options-driven group — arrow-key navigation comes native via the shared name