Skip to content
Tier

Progress button

Pro

A button that fills as the work it started goes: an upload, an export, a sync. While working it fills across its width only as far as the measured progress (0 to 1), and never ahead of it; with progress={null} it doesn't fill at all, shows a spinner and says the progress is unknown. With files, the fill is one segment per file, each to its own progress, and the label counts them ("3 files, 1 done"), naming any that failed. It ends on the result the app sets: a tick for done, a cross and what failed for failed, announced once.

The button is one element throughout, so focus stays on it. While working it is aria-disabled and aria-busy (never disabled), and a press does nothing; so does one while the app sets aria-disabled (or wraps it in Locked), by pointer or keyboard, and the button stays focusable and says why. The measured percentage is its description ("40% done"), read with its name; it has no aria-valuenow, which a button can't carry. In forced colours the fill is drawn as a bar along its bottom edge in the text colour, since a fill behind the label would vanish.

Markdown
Version 5.2.0· other versions

This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).

Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0, 2.8.0, 2.9.0, 2.10.0, 3.0.0, 4.0.0, 4.0.1, 4.0.2, 4.1.0, 4.2.0 and 4.3.0: the page came later, or had another name then.

Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.

Preview

Loading the preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/progress-button

Brings in: bidi, button, locale, motion, status, tokens, use-announcer, utils · npm: lucide-react@^1.67.0

Usage

TSX · The example above
"use client"

import * as React from "react"
import { ProgressButton, type ProgressButtonFile, type ProgressButtonState } from "@/components/candor/ui/progress-button"
import { Button } from "@/components/candor/ui/button"

/*
 * The uploads here stand in for your own: each file's progress is what the upload reports, a step at a time, and the
 * button fills only that far. The last file fails, and the button names it.
 */
const start: ProgressButtonFile[] = [
  { id: "a", name: "q3-report.pdf", progress: 0, status: "running" },
  { id: "b", name: "board-notes.docx", progress: 0, status: "running" },
  { id: "c", name: "forecast.xlsx", progress: 0, status: "running" },
]

export default function Example() {
  const [state, setState] = React.useState<ProgressButtonState>("idle")
  const [files, setFiles] = React.useState<ProgressButtonFile[]>(start)
  const timer = React.useRef<ReturnType<typeof setInterval> | undefined>(undefined)
  React.useEffect(() => () => clearInterval(timer.current), [])
  const upload = () => {
    setState("working")
    setFiles(start)
    let tick = 0
    timer.current = setInterval(() => {
      tick += 1
      setFiles((was) =>
        was.map((f, i) => {
          if (f.status !== "running") return f
          const progress = Math.min(1, tick * (0.12 + i * 0.05))
          if (i === 2 && progress >= 0.6) return { ...f, progress, status: "failed" }
          return { ...f, progress, status: progress >= 1 ? "succeeded" : "running" }
        })
      )
      if (tick >= 9) {
        clearInterval(timer.current)
        setState("failed")
      }
    }, 400)
  }
  const overall = files.reduce((sum, f) => sum + (f.progress ?? 0), 0) / files.length
  return (
    <div className="flex flex-wrap items-center gap-3">
      <ProgressButton
        state={state}
        progress={overall}
        files={state === "idle" ? undefined : files}
        labels={{ idle: "Upload 3 files", working: "Uploading…", done: "Uploaded", failed: "Couldn't upload every file" }}
        onClick={upload}
      />
      <ProgressButton state="working" progress={null} labels={{ idle: "Sync", working: "Syncing…", done: "Synced" }} variant="outline" />
      {state !== "idle" && state !== "working" && (
        <Button variant="ghost" size="sm" onClick={() => setState("idle")}>
          Reset the example
        </Button>
      )}
    </div>
  )
}

<ProgressButton>

PropTypeDefaultDescription
progressrequirednumber | null—How far the work has got, from 0 to 1, as measured; `null` when it isn't known. Never estimated.
staterequiredProgressButtonState—Where the work is: the app's, from the real result. `done` only once the service has confirmed it.
labelsrequired{ /** The action, as a verb: "Upload". */ idle: string; /** While the work runs: "Uploading…". */ working: string; /** After the service confirmed it: "Uploaded". */ done: string; /** After it failed: "Couldn't upload". Defaults to "That didn't work". */ failed?: string | undefined; }—The words for each state: the action ("Upload"), working ("Uploading…"), done ("Uploaded") and failed.
filesProgressButtonFile[] | undefined—Several files sent by one press, each with its own measured progress and status.

Also takes the props of Omit<React.ComponentProps<typeof Button>, "children">.

Strings

The fixed words, in English by default. Change any of them under progressButton in <LocaleProvider strings={{ … }}>; the table is exported as progressButtonStrings.

KeyEnglish
failedThat didn't work
measured{percent} done
unknown{working}, progress unknown
files{ one: "{count} file, {done} done", other: "{count} files, {done} done" }
failedFilesFailed: {names}
measuredFailed{percent} done. Failed: {names}
failedWithFiles{label}. Failed: {names}

Motion

MomentFamilyWhat it does
progressfillWhile the work runs, the button fills across its width as far as the measured progress, and moves between measured values, never ahead of them. With several files each segment fills to its own file's progress. Under reduced motion it snaps to each value.
resultmark, drawThe tick (done) or the cross (failed) is drawn along its path when the app sets the result while the button is on screen. A button drawn already done or failed shows its mark at once.
workingliveWhile the work runs and its progress isn't known, a spinner turns beside the working label in place of a fill. Under reduced motion it is still, with its words.

Set with --cd-motion-progress-button-<moment>-* in your brand file; the families are in the design reference.

Types

ProgressButtonFile
One file of several sent by one press, with its own measured progress.
{ /** A stable id. */ id: string; /** The file's name, as the person knows it: "report.pdf". */ name: string; /** How far it has got, from 0 to 1, as measured; `null` when it isn't known. */ progress: number | null; /** Where it is: `running` while it is sent, `succeeded` once confirmed, `failed` when it didn't arrive. */ status: RunStatus; }
ProgressButtonState
Where the work a ProgressButton starts has got to, as the app knows it.
"idle" | "working" | "done" | "failed"

Types from other items

The props above take these. Each is defined by another item; import it from the path shown.

RunStatus from @/lib/candor/status
The status vocabulary every Candor agent component shares. Components take plain data in these shapes rather than an SDK's message objects, so they work with your own LLM drivers, the Vercel AI SDK or anything else; adapters map an SDK's parts onto them.
"idle" // not started | "queued" // accepted, waiting for a worker | "running" // an agent is working on it now | "waiting" // stopped to ask a person (approval, question) | "blocked" // can't start: something it depends on failed or is waiting | "succeeded" | "failed" | "cancelled" | "skipped"