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-plus

layer

core / patterns

intensity

structures · organizes a region, stays out of the content

composition

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 slot

a11y

role="progressbar" with aria-valuenow/min/max · a string label becomes the bar's aria-label, pass one yourself otherwisejest-axe
live demo · try it out
Computer Science
In progress
12 / 30 cr
customize
labelthe row's name
valuecurrent amount
12
maxtarget amount
30
unittrailing figure unit
barColorcategorical key color, not a verdict
statusa slot, usually a Badge
usagecopy
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

proptypedefaultnotes
labelReactNodethe row's name. doubles as the bar's accessible name when a string.
valuenumbercurrent amount. clamped, overshoot fills the bar.
maxnumbertarget amount. a max of 0 renders an empty bar, not a division by zero.
unitstringtrailing unit on the figures, e.g. "cr".
statusReactNodea slot, usually a Badge. never derived from the ratio.
barColorstringcategorical 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.jsoncopy
source · components/ui/progress-row.tsxexactly what this command copiescopy
"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";