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
A label, a 4px bar and the count on the right.
A row of three
Three goals in a grid, one with a custom format and footnotes.
Complete
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
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | What is being measured. | |
| value | number | The current value. Clamped to 0 and max for the bar. | |
| max | number | 100 | The goal. |
| format | (value: number, max: number) => string | "1,284 / 2,000" | Formats the count on the right. |
| footnote | string | One 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