Skip to contentVibraUI
Feedback & status

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-progress

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
tasksTaskProgressItem[]—One row each, in the order they run.
tasks[].idstring—React key, and what onCancel is called with.
tasks[].labelReact.ReactNode—Names the step; a plain string also names its bar.
tasks[].progressnumber—0 to 100; anything outside that is clamped rather than overflowing the track.
tasks[].status"pending" | "running" | "done" | "error"done at 100, else pendingSets the row icon and the bar's tone; done turns it green, error red, running spins.
tasks[].detailReact.ReactNode—A line under the bar — rows processed, bytes copied, the error.
titleReact.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.
collapsiblebooleanfalseTurns the header into a trigger that folds the tasks away; the count and percentage stay readable.

Dependencies

Source

components/ui/task-progress.tsx
"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 }