Task progress
A multi-step run: how many steps are done, one thin bar per step, and a cancel for work still in flight.
A client component — the cancel buttons and the collapsible header need handlers. A task counts as complete when its status is done, and a task with no status at all is done once its bar reaches 100; the header percentage is the mean of every bar, so a run of five tasks half-finished reads 50 percent, not 0. Progress outside 0 to 100 is clamped rather than overflowing the track. Each bar is a progressbar named after its task when the label is plain text, and every row carries its status as visually hidden text, so the icon's colour is never the only signal. The div's own title and onCancel props are replaced by the ones documented here.
Install
npx shadcn@latest add @vibra/task-progressNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { TaskProgress, type TaskProgressItem } from "@/components/ui/task-progress"
const INITIAL: TaskProgressItem[] = [
{ id: "extract", label: "Extract orders from the store", progress: 100, status: "done" },
{ id: "normalise", label: "Normalise currencies", progress: 100, status: "done" },
{
id: "load",
label: "Load into the warehouse",
progress: 62,
status: "running",
detail: "638k of 1.03M rows",
},
{ id: "verify", label: "Verify row counts", progress: 0, status: "pending" },
{
id: "publish",
label: "Publish the revenue dashboards",
progress: 0,
status: "error",
detail: "Upstream table orders_daily is missing",
},
]
export default function TaskProgressDemo() {
const [tasks, setTasks] = React.useState(INITIAL)
return (
<div className="w-full max-w-lg rounded-lg border p-4">
<TaskProgress
title="Nightly sync"
tasks={tasks}
collapsible
onCancel={(id) =>
setTasks((current) =>
current.map((task) =>
task.id === id ? { ...task, status: "error", detail: "Cancelled" } : task
)
)
}
/>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| tasks | TaskProgressItem[] | — | One row each, in the order they run. |
| tasks[].id | string | — | React key, and what onCancel is called with. |
| tasks[].label | React.ReactNode | — | Names the step; a plain string also names its bar. |
| tasks[].progress | number | — | 0 to 100; anything outside that is clamped rather than overflowing the track. |
| tasks[].status | "pending" | "running" | "done" | "error" | done at 100, else pending | Sets the row icon and the bar's tone; done turns it green, error red, running spins. |
| tasks[].detail | React.ReactNode | — | A line under the bar — rows processed, bytes copied, the error. |
| title | React.ReactNode | — | Names the run, at the start of the header row. |
| onCancel | (id: string) => void | — | Renders a cancel button on every task still pending or running. |
| collapsible | boolean | false | Turns the header into a trigger that folds the tasks away; the count and percentage stay readable. |
Dependencies
Source
"use client"
import * as React from "react"
import { ChevronDownIcon, CircleCheckIcon, CircleIcon, CircleXIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { clamp } from "@/lib/format"
import { Button } from "@/components/ui/button"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { Spinner } from "@/components/ui/spinner"
/** Where one task in a run has got to. */
export type TaskProgressStatus = "pending" | "running" | "done" | "error"
// Read out beside every row, so the icon's colour is never the only signal.
const STATUS_LABELS: Record<TaskProgressStatus, string> = {
pending: "Pending",
running: "Running",
done: "Done",
error: "Failed",
}
// Colour is spent on the two outcomes a reader has to act on; work still in
// flight keeps the near-ink bar the rest of the registry uses.
const STATUS_INDICATORS: Record<TaskProgressStatus, string> = {
pending: "bg-primary",
running: "bg-primary",
done: "bg-success",
error: "bg-danger",
}
function TaskProgressIcon({ status }: { status: TaskProgressStatus }) {
if (status === "running") return <Spinner className="size-4 text-muted-foreground" />
if (status === "done") return <CircleCheckIcon className="size-4 text-success" />
if (status === "error") return <CircleXIcon className="size-4 text-danger" />
return <CircleIcon className="size-4 text-muted-foreground/50" />
}
export type TaskProgressItem = {
id: string
label: React.ReactNode
/** 0 to 100; anything outside that is clamped rather than overflowing the track. */
progress: number
/** Left off, it is derived from progress: done at 100, pending below it. */
status?: TaskProgressStatus
/** A second line under the bar — rows processed, bytes copied, the current file. */
detail?: React.ReactNode
}
// `title` is content here rather than the HTML tooltip attribute, and onCancel
// takes a task id rather than a DOM cancel event, so both replace the div prop
// of the same name instead of intersecting with it.
export type TaskProgressProps = Omit<React.ComponentProps<"div">, "title" | "onCancel"> & {
tasks: TaskProgressItem[]
title?: React.ReactNode
/** Renders a cancel button on every task still pending or running. */
onCancel?: (id: string) => void
/** Folds the tasks away behind the header, which stays readable on its own. */
collapsible?: boolean
}
function TaskProgress({
className,
tasks,
title,
onCancel,
collapsible = false,
...props
}: TaskProgressProps) {
const rows = tasks.map((task) => {
const percent = Math.round(clamp(task.progress, 0, 100))
// One rule for both: a task is done when it says so, and a task that never
// says anything is done once its bar is full.
const status = task.status ?? (percent >= 100 ? "done" : "pending")
return { task, percent, status }
})
const completed = rows.filter((row) => row.status === "done").length
const overall =
rows.length === 0
? 0
: Math.round(rows.reduce((sum, row) => sum + row.percent, 0) / rows.length)
const header = (
<>
<span className="min-w-0 flex-1 truncate text-start">
{title ? (
<span data-slot="task-progress-title" className="text-sm font-medium">
{title}
</span>
) : null}
</span>
<span data-slot="task-progress-count" className="shrink-0 text-xs text-muted-foreground">
{`${completed} of ${rows.length} complete`}
</span>
<span
data-slot="task-progress-overall"
className="shrink-0 text-sm font-medium tabular-nums"
>
{`${overall}%`}
</span>
</>
)
const list = (
<div data-slot="task-progress-tasks" className="flex flex-col gap-3">
{rows.map(({ task, percent, status }, index) => {
// A ReactNode label cannot become a string, so a rich label falls back
// to the task's place in the run. It has to be something, and it has to
// differ per row: a panel of five bars all named the same, or none of
// them named at all, is a panel a screen reader cannot tell apart.
const name = typeof task.label === "string" ? task.label : `Task ${index + 1}`
const cancellable = Boolean(onCancel) && (status === "pending" || status === "running")
return (
<div
key={task.id}
data-slot="task-progress-task"
data-status={status}
className="flex flex-col gap-1.5"
>
<div className="flex items-center gap-2">
<span data-slot="task-progress-icon" aria-hidden="true" className="flex shrink-0">
<TaskProgressIcon status={status} />
</span>
<span className="sr-only">{`${STATUS_LABELS[status]}. `}</span>
<span
data-slot="task-progress-label"
className={cn(
"min-w-0 flex-1 truncate text-sm",
status === "error" && "text-danger"
)}
>
{task.label}
</span>
{cancellable ? (
<Button
type="button"
data-slot="task-progress-cancel"
variant="ghost"
size="icon-xs"
// The icon carries no text, and "Cancel" alone would name
// every button on the panel the same thing.
aria-label={`Cancel ${name}`}
onClick={() => onCancel?.(task.id)}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<XIcon aria-hidden="true" />
</Button>
) : null}
<span
data-slot="task-progress-percent"
className="w-9 shrink-0 text-end text-xs font-medium tabular-nums text-muted-foreground"
>
{`${percent}%`}
</span>
</div>
<div
data-slot="task-progress-track"
role="progressbar"
aria-label={name}
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={`${percent}%`}
className="h-1 w-full overflow-hidden rounded-full bg-muted"
>
<div
data-slot="task-progress-indicator"
className={cn(
"h-full rounded-full transition-[width] duration-(--duration-slow) ease-(--ease-standard)",
STATUS_INDICATORS[status]
)}
style={{ width: `${percent}%` }}
/>
</div>
{task.detail ? (
<span data-slot="task-progress-detail" className="text-xs text-muted-foreground">
{task.detail}
</span>
) : null}
</div>
)
})}
</div>
)
return (
<div
data-slot="task-progress"
data-collapsible={collapsible || undefined}
className={cn("flex w-full flex-col gap-3", className)}
{...props}
>
{collapsible ? (
<Collapsible defaultOpen className="flex w-full flex-col gap-3">
<CollapsibleTrigger
data-slot="task-progress-header"
className="group/task-progress-header flex w-full items-baseline gap-3 rounded-md focus-ring"
>
{header}
<ChevronDownIcon
aria-hidden="true"
className="size-4 shrink-0 self-center text-muted-foreground transition-transform duration-(--duration-fast) ease-(--ease-standard) group-aria-expanded/task-progress-header:rotate-180"
/>
</CollapsibleTrigger>
<CollapsibleContent>{list}</CollapsibleContent>
</Collapsible>
) : (
<>
<div
data-slot="task-progress-header"
className="flex items-baseline gap-3"
>
{header}
</div>
{list}
</>
)}
</div>
)
}
export { TaskProgress }