Skip to contentVibraUI
Metrics

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

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
labelReact.ReactNode—What is being tracked, e.g. "Enterprise".
valuenumber—How far along the metric is.
targetnumber—What the value is working toward; the bar fills as it closes in.
format(n: number) => stringwhole units with separatorsFormats the value and the target alike.
showPercentbooleantrueShows 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

Source

components/ui/progress-stat.tsx
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 }