Progress stat
A value against the target it is working toward, on a bar coloured by how close it is.
Server-compatible: no hooks, no client boundary. progressTone(value, target) is exported, so a caller can colour something else the same way. The reading and the percentage are always text, so the tone never carries the meaning alone. The bar is named for screen readers only when the label is a plain string; the reading above it covers the rest.
Install
$
npx shadcn@latest add @vibra/progress-statNeeds the @vibra registry in your components.json — set it up once.
Examples
import { ProgressStat } from "@/components/ui/progress-stat"
import { formatCurrency } from "@/lib/format"
const money = (value: number) =>
formatCurrency(value, "USD", { compact: true, maximumFractionDigits: 1 })
// Four segments against their own targets, so the auto tone walks the whole
// scale: past target, on track, slipping, and behind.
const SEGMENTS = [
{ label: "Enterprise", value: 1840000, target: 1600000 },
{ label: "Mid-market", value: 742000, target: 950000 },
{ label: "SMB", value: 410000, target: 820000 },
{ label: "Partners", value: 186000, target: 620000 },
]
export default function ProgressStatDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-5 panel p-6">
<div className="text-sm font-medium">Q3 quota attainment</div>
{SEGMENTS.map((segment) => (
<ProgressStat
key={segment.label}
label={segment.label}
value={segment.value}
target={segment.target}
format={money}
/>
))}
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | React.ReactNode | — | What is being tracked, e.g. "Enterprise". |
| value | number | — | How far along the metric is. |
| target | number | — | What the value is working toward; the bar fills as it closes in. |
| format | (n: number) => string | whole units with separators | Formats the value and the target alike. |
| showPercent | boolean | true | Shows the share of the target beside the bar. |
| tone | "default" | "success" | "warning" | "danger" | "info" | "auto" | "auto" | auto is success at or past the target, default from 70 percent of it, warning from 40, danger below. |
| size | "sm" | "default" | "default" | sm tightens the row and thins the bar to h-1.5. |
Dependencies
Registry
Source
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { clamp, formatNumber, percentOf } from "@/lib/format"
type Tone = "default" | "success" | "warning" | "danger" | "info"
/** The tone progress toward a target should take: success at or past it, default from 70% of it, warning from 40%, danger below that. */
export function progressTone(value: number, target: number): Tone {
const percent = percentOf(value, target)
if (percent >= 100) return "success"
if (percent >= 70) return "default"
if (percent >= 40) return "warning"
return "danger"
}
const progressStatIndicatorVariants = cva("h-full rounded-full transition-[width] duration-(--duration-slow) ease-(--ease-standard)", {
variants: {
tone: {
// Near-ink while the target is still in reach — colour is reserved for
// the point where the reader has to act.
default: "bg-primary",
success: "bg-success",
warning: "bg-warning",
danger: "bg-danger",
info: "bg-info",
},
},
defaultVariants: { tone: "default" },
})
const DEFAULT_FORMAT = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
export type ProgressStatProps = React.ComponentProps<"div"> & {
label: React.ReactNode
value: number
/** What the value is working toward; the bar fills as it closes in. */
target: number
/** Formats both numbers. Defaults to whole units with separators. */
format?: (n: number) => string
/** Shows the share of the target beside the bar. */
showPercent?: boolean
/** "auto" derives the tone from how close the value is to the target. */
tone?: Tone | "auto"
size?: "sm" | "default"
}
function ProgressStat({
className,
label,
value,
target,
format = DEFAULT_FORMAT,
showPercent = true,
tone = "auto",
size = "default",
...props
}: ProgressStatProps) {
const resolvedTone = tone === "auto" ? progressTone(value, target) : tone
const percent = Math.round(clamp(percentOf(value, target), 0, 100))
const reading = `${format(value)} / ${format(target)}`
return (
<div
data-slot="progress-stat"
data-tone={resolvedTone}
data-size={size}
className={cn("flex w-full flex-col gap-2", size === "sm" && "gap-1.5", className)}
{...props}
>
<div
data-slot="progress-stat-header"
className="flex items-baseline justify-between gap-3"
>
<span
data-slot="progress-stat-label"
className={cn("min-w-0 truncate text-muted-foreground", size === "sm" ? "text-xs" : "text-sm")}
>
{label}
</span>
<span
data-slot="progress-stat-value"
className={cn("shrink-0 font-medium tabular-nums", size === "sm" ? "text-xs" : "text-sm")}
>
{format(value)}
<span className="font-normal text-muted-foreground">{` / ${format(target)}`}</span>
</span>
</div>
<div className="flex items-center gap-3">
<div
data-slot="progress-stat-track"
role="progressbar"
// A ReactNode label cannot become a string, so the bar is named only
// when the label is plain text; the reading above it covers the rest.
aria-label={typeof label === "string" ? label : undefined}
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={reading}
className={cn(
"min-w-0 flex-1 overflow-hidden rounded-full bg-muted",
size === "sm" ? "h-1.5" : "h-2"
)}
>
<div
data-slot="progress-stat-indicator"
className={progressStatIndicatorVariants({ tone: resolvedTone })}
style={{ width: `${percent}%` }}
/>
</div>
{showPercent ? (
<span
data-slot="progress-stat-percent"
className={cn(
"shrink-0 text-end text-muted-foreground tabular-nums",
size === "sm" ? "min-w-8 text-xs" : "min-w-10 text-sm"
)}
>{`${percent}%`}</span>
) : null}
</div>
</div>
)
}
export { ProgressStat, progressStatIndicatorVariants }