Progress button
ProA 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.
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
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
"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>
| Prop | Type | Default | Description |
|---|---|---|---|
| progressrequired | number | null | — | How far the work has got, from 0 to 1, as measured; `null` when it isn't known. Never estimated. |
| staterequired | ProgressButtonState | — | 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. |
| files | ProgressButtonFile[] | 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.
| Key | English |
|---|---|
| failed | That didn't work |
| measured | {percent} done |
| unknown | {working}, progress unknown |
| files | { one: "{count} file, {done} done", other: "{count} files, {done} done" } |
| failedFiles | Failed: {names} |
| measuredFailed | {percent} done. Failed: {names} |
| failedWithFiles | {label}. Failed: {names} |
Motion
| Moment | Family | What it does |
|---|---|---|
| progress | fill | While 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. |
| result | mark, draw | The 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. |
| working | live | While 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"