Progress
How far a task has got. The fill is the pending yellow with an ink leading edge, the readout gains the done glyph at the end, and a failed task stays where it stopped in red. Say why it failed and what to do in the detail line.
Loading the live example…
"use client"
import { useEffect, useRef, useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import { Progress } from "@/components/keyline/ui/progress"
const SIZE = 4.8 // MB
export function UploadReport() {
const [sent, setSent] = useState<number | null>(null)
const [failed, setFailed] = useState(false)
const tries = useRef(0)
const timer = useRef<ReturnType<typeof setInterval>>(undefined)
useEffect(() => () => clearInterval(timer.current), [])
const upload = () => {
tries.current += 1
// the first try drops at 62%, as corporate's server does on Fridays; the retry goes through
const dropAt = tries.current === 1 ? 62 : Infinity
setFailed(false)
setSent(0)
clearInterval(timer.current)
timer.current = setInterval(() => {
setSent((v) => {
const next = Math.min(100, (v ?? 0) + 7)
if (next >= dropAt) {
clearInterval(timer.current)
setFailed(true)
return dropAt
}
if (next === 100) clearInterval(timer.current)
return next
})
}, 180)
}
const mb = sent == null ? 0 : (SIZE * sent) / 100
const running = sent != null && sent < 100 && !failed
return (
<div className="grid max-w-[480px] gap-4">
<Progress
label="Q4 sales report.xlsx to corporate"
value={sent ?? 0}
failed={failed}
detail={
failed
? "The connection to Stamford dropped. Nothing was saved there; try again."
: sent === 100
? `${SIZE} MB sent. Jan in New York has it.`
: sent == null
? `${SIZE} MB, not sent yet.`
: `${mb.toFixed(1)} of ${SIZE} MB · about ${Math.ceil(((100 - sent) / 7) * 0.18)} s left`
}
/>
<div className="flex flex-wrap gap-3">
<Button variant="primary" disabled={running} onClick={upload}>
{failed ? "Try again" : sent === 100 ? "Send again" : "Send report"}
</Button>
</div>
</div>
)
}Installation
npx shadcn@latest add @keyline-ui/progressRun it where your components.json is. The first time, map the @keyline-ui registry: one command, in Installation.
Usage
import { Progress } from "@/components/keyline/ui/progress"<Progress
label="Q4 sales report.xlsx to corporate"
value={sent}
failed={dropped}
detail="2.9 of 4.8 MB · about 3 s left"
/>Examples
Before there is a figure
value= runs the pending segment along the rail until there is something to count. Here the printer warms up first, then counts pages.
Loading the live example…
"use client"
import { useEffect, useRef, useState } from "react"
import { Button } from "@/components/keyline/ui/button"
import { Progress } from "@/components/keyline/ui/progress"
const PAGES = 40
export function PrintInvoices() {
// null while the printer warms up: there is no figure to show yet, so the bar is indeterminate
const [printed, setPrinted] = useState<number | null>(0)
const [started, setStarted] = useState(false)
const timers = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => () => timers.current.forEach(clearTimeout), [])
const print = () => {
timers.current.forEach(clearTimeout)
setStarted(true)
setPrinted(null)
timers.current = [setTimeout(() => setPrinted(0), 2000)]
for (let p = 1; p <= PAGES; p++) timers.current.push(setTimeout(() => setPrinted(p), 2000 + p * 70))
}
return (
<div className="grid max-w-[480px] gap-4">
<Progress
label="Invoices on the second-floor printer"
value={printed}
max={PAGES}
getAriaValueText={(_, v) => (v == null ? "Warming up" : `${v} of ${PAGES} pages`)}
detail={!started ? `${PAGES} pages, waiting to start.` : printed == null ? "Warming up. It takes a while; it always has." : printed === PAGES ? "All 40 printed. Collect them before Creed does." : `${printed} of ${PAGES} pages`}
/>
<Button className="justify-self-start" disabled={started && printed !== PAGES} onClick={print}>
{printed === PAGES ? "Print again" : "Print"}
</Button>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | Default — | What is in progress. |
value | number | null | Default — | How far it has got. null while it can't be counted. |
max | number | Default 100 | The value at which it is complete. |
detail | ReactNode | Default — | A line under the rail: amounts and time left, or why it failed and what to do. |
failed | boolean | Default false | The task stopped. value keeps how far it got. |
failedText | string | Default "Failed" | The readout when failed. |
hideLabel | boolean | Default false | Only when the text around it already gives the label and figure, as in a table cell. |
Under reduced motion the pending segment is hatched and still. Built on Base UI Progress; format, locale and getAriaValueText pass through.