Skip to content
goodtabs UI
BrowseBrowse components

Progress card

A goal with a monochrome bar, a mono count on the right and a check when it is met.

npx shadcn@latest add https://ui.goodtabs.co/r/progress-card.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/progress-card

Waitlist signups

1,284 / 2,000

A label, a 4px bar and the count on the right.

A row of three

Components reviewed

38 / 60

22 left in the queue

Storage used

6.4 / 10 GB

Resets 1 Nov

Monthly goal

420 / 1,000

On pace for 980

Three goals in a grid, one with a custom format and footnotes.

Complete

Gold set reviewed

Complete: 100%

All ten kept, no redo notes

At 100 percent a check mark joins the count and the bar is full.

Usage

import { ProgressCard } from "@/components/ui/progress-card"

<ProgressCard label="Waitlist signups" value={1284} max={2000} footnote="Opens at 2,000" />

Props

PropTypeDefaultDescription
labelstringWhat is being measured.
valuenumberThe current value. Clamped to 0 and max for the bar.
maxnumber100The goal.
format(value: number, max: number) => string"1,284 / 2,000"Formats the count on the right.
footnotestringOne quiet line under the bar.

Source

progress-card/progress-card.tsx
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";

type ProgressCardProps = Omit<React.ComponentProps<typeof Card>, "children"> & {
  /** What is being measured, for example "Waitlist signups". */
  label: string;
  /** The current value. */
  value: number;
  /** The goal. */
  max?: number;
  /** Formats the count on the right. Defaults to "1,284 / 2,000". */
  format?: (value: number, max: number) => string;
  /** One quiet line under the bar. */
  footnote?: string;
};

const number = new Intl.NumberFormat("en-US");
const defaultFormat = (value: number, max: number) => `${number.format(value)} / ${number.format(max)}`;

/**
 * A goal with a monochrome progress bar and the count on the right in mono.
 * A check mark joins the count once the goal is met.
 */
export function ProgressCard({
  label,
  value,
  max = 100,
  format = defaultFormat,
  footnote,
  className,
  ...props
}: ProgressCardProps) {
  const clamped = Math.min(Math.max(value, 0), max);
  const percent = max > 0 ? (clamped / max) * 100 : 0;
  const complete = max > 0 && clamped >= max;
  const count = format(value, max);

  return (
    <Card size="sm" data-complete={complete || undefined} className={cn("gap-2", className)} {...props}>
      <CardContent className="flex flex-col gap-2">
        <div className="flex items-baseline justify-between gap-3">
          <p className="m-0 truncate text-sm text-foreground">{label}</p>
          <p className="m-0 inline-flex shrink-0 items-center gap-1 font-mono text-xs text-muted-foreground tabular-nums">
            {complete && (
              <>
                <CheckIcon className="size-3.5 self-center text-foreground" aria-hidden="true" />
                <span className="sr-only">Complete: </span>
              </>
            )}
            {count}
          </p>
        </div>
        <div
          role="progressbar"
          aria-label={label}
          aria-valuemin={0}
          aria-valuemax={max}
          aria-valuenow={clamped}
          aria-valuetext={count}
          className="h-1 w-full overflow-hidden rounded-full bg-muted"
        >
          <div
            data-slot="progress-card-fill"
            className="h-full rounded-full bg-foreground transition-[width] duration-200 ease-out motion-reduce:transition-none"
            style={{ width: `${percent}%` }}
          />
        </div>
        {footnote && <p className="m-0 text-xs text-muted-foreground">{footnote}</p>}
      </CardContent>
    </Card>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/progress-card/prompt.md, or the Copy AI prompt button above.

When to use

  • Quotas, usage and goals on a dashboard or settings page
  • A funding or signup target on a marketing page
  • Any count that only makes sense next to its ceiling

When not to use

  • Indeterminate loading; use a spinner or skeleton
  • Many bars to compare at once, where a chart does better
  • Values with no ceiling; use stat-card

Agent notes

  • Server component. It extends shadcn's Card, so className and other div props pass through
  • format is a function; pass it from a server or client component alike, the card itself holds no state
  • The bar is monochrome by design, bg-foreground on bg-muted; the check mark is the only completion signal
  • The fill width animates over 200ms when value changes and not at all under prefers-reduced-motion