docs / patterns / progress-row
ProgressRow
a labelled progress bar with a value-over-max figure. the bar color keys the row to a category, and any verdict goes in the status slot.
intensity · structuresrsc · client
provenance
shaped from sisu-pluslayer
core / patternsintensity
structures · organizes a region, stays out of the contentcomposition
stack inside a Card body with divide-y for a module lista Badge in the status slot when a row needs a verdictnot a disclosure. no chevron, no aria-expanded, no wrapping buttonno traffic-light barColor. status has its own slota11y
role="progressbar" with aria-valuenow/min/max · a string label becomes the bar's aria-label, pass one yourself otherwisejest-axelive demo · try it out
Computer Science
In progress
12 / 30 cr
customize
labelthe row's name
valuecurrent amount
maxtarget amount
unittrailing figure unit
barColorcategorical key color, not a verdict
statusa slot, usually a Badge
usage
import { ProgressRow } from "usva/patterns/progress-row";
import { Badge } from "usva/primitives/badge";
<ProgressRow
label="Computer Science"
value={12}
max={30}
unit="cr"
barColor="#8b5cf6"
status={<Badge tone="warning">In progress</Badge>}
/>props
| prop | type | default | notes |
|---|---|---|---|
| label | ReactNode | — | the row's name. doubles as the bar's accessible name when a string. |
| value | number | — | current amount. clamped, overshoot fills the bar. |
| max | number | — | target amount. a max of 0 renders an empty bar, not a division by zero. |
| unit | string | — | trailing unit on the figures, e.g. "cr". |
| status | ReactNode | — | a slot, usually a Badge. never derived from the ratio. |
| barColor | string | — | categorical key color, any CSS color. it says which row this is, not how it is doing. |
get it
npx shadcn add https://usva.build/r/progress-row.jsonsource · components/ui/progress-row.tsxexactly what this command copies
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface ProgressRowProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
label: React.ReactNode;
value: number;
max: number;
/** Trailing unit on the figures, e.g. "cr". */
unit?: string;
/** A slot, usually a Badge. ProgressRow never derives status from the ratio. */
status?: React.ReactNode;
/**
* Categorical key colour, any CSS colour, following StripeCard's stripeColor.
* It says which module this row is, not how it is doing.
*/
barColor?: string;
/** Accessible name for the bar. Defaults to `label` when that is a string. */
"aria-label"?: string;
}
function ratio(value: number, max: number): number {
if (!(max > 0)) return 0;
return Math.min(Math.max(value / max, 0), 1);
}
/**
* A labelled progress bar with mono figures and a status slot.
*
* Deliberately not a disclosure: sisu's SectionHeader wraps this shape in a
* button with a chevron and aria-expanded. That accordion is a separate
* concern and does not belong inside a progress row.
*/
export const ProgressRow = React.forwardRef<HTMLDivElement, ProgressRowProps>(
(
{
className,
label,
value,
max,
unit,
status,
barColor,
"aria-label": ariaLabel,
...props
},
ref,
) => {
const percent = ratio(value, max) * 100;
const name = ariaLabel ?? (typeof label === "string" ? label : undefined);
const [fill, setFill] = React.useState(0);
React.useEffect(() => {
setFill(percent);
}, [percent]);
return (
<div
ref={ref}
className={cn("flex items-center gap-4 py-3", className)}
{...props}
>
<div className="min-w-0 flex-1">
<div className="mb-2 truncate text-sm font-semibold text-ink">
{label}
</div>
<div
role="progressbar"
aria-label={name}
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={max}
className="block h-1.5 max-w-96 overflow-hidden rounded-full bg-surface-2"
>
<span
data-testid="progress-row-fill"
className={cn(
"block h-full rounded-full transition-[width] duration-slow ease-soft motion-reduce:transition-none",
barColor == null && "bg-accent",
)}
style={{
width: `${fill}%`,
backgroundColor: barColor,
}}
/>
</div>
</div>
{status != null && <div className="shrink-0">{status}</div>}
<div className="min-w-20 shrink-0 text-right font-mono text-sm tabular-nums">
<span className="font-semibold text-ink">{value}</span>
<span className="text-muted">
{" / "}
{max}
{unit != null && ` ${unit}`}
</span>
</div>
</div>
);
},
);
ProgressRow.displayName = "ProgressRow";