docs / patterns / stat-card
StatCard
one metric in a tile: a big value, a quiet unit, and a colored trend. one featured tile per grid; the rest stay smaller.
intensity · structuresrsc · server
provenance
shaped from personal-websitelayer
core / patternsintensity
structures · organizes a region, stays out of the contentcomposition
grids of two to four on a dashboard, or inside DashboardGridthe spark slot takes a sparkline that stretches to the tile widthnot a marketing proof strip, that is StatBentono actions inside. a metric that opens something wraps in a linka11y
the icon and trend glyph arearia-hidden · the trend meaning is duplicated by the note text, never color alonejest-axedependencies
Card from the same packagelive demo · try it out
Revenue
48.2k
8.1% MoM
Response time
128ms
12% faster
Active users
2,940
steady
Error rate
0.03%
within SLO
featured · tone
Stars
12.4k
all-time
Uptime
99.98%
30-day
props
| prop | type | default | notes |
|---|---|---|---|
| label | React.ReactNode | — | the uppercase metric label at the top. |
| value | React.ReactNode | — | the figure. mono, tabular, the loudest thing in the tile. |
| unit | React.ReactNode | — | quiet suffix beside the value, e.g. ms, %. |
| note | React.ReactNode | — | the footnote beside the trend glyph. |
| icon | React.ReactNode | — | decorative glyph chip in the top-right corner. |
| trend | "up" | "down" | "flat" | — | colors the note and prepends a directional glyph. up is success, down is danger. |
| size | "sm" | "md" | "md" | padding and value size. |
| featured | boolean | false | blows the tile up to a hero stat with a much larger numeral. |
| tone | "neutral" | "accent" | "accent-alt" | "neutral" | tints the border and colors the value. |
| surface | CardSurface | "elevated" | how the tile sits on the page. passed through to Card. |
| spark | React.ReactNode | — | slot for a sparkline or mini-chart, stretched to fill. |
get it
npx shadcn add https://usva.build/r/stat-card.jsonsource · components/ui/stat-card.tsxexactly what this command copies
import * as React from "react";
import { cn } from "@/lib/utils";
import {
Card,
CardBody,
type CardSurface,
} from "./card";
export type StatTrend = "up" | "down" | "flat";
export type StatTone = "neutral" | "accent" | "accent-alt";
export interface StatCardProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
label: React.ReactNode;
value: React.ReactNode;
unit?: React.ReactNode;
note?: React.ReactNode;
icon?: React.ReactNode;
trend?: StatTrend;
size?: "sm" | "md";
/** Blow the tile up to a hero stat: larger numeral, more presence. */
featured?: boolean;
/** Tints the border and colors the value. Defaults to neutral. */
tone?: StatTone;
/** How the tile sits on the page. Defaults to elevated. */
surface?: CardSurface;
spark?: React.ReactNode;
}
const trendTone: Record<StatTrend, string> = {
up: "text-success",
down: "text-danger",
flat: "text-muted",
};
const trendGlyph: Record<StatTrend, string> = {
up: "↑",
down: "↓",
flat: "→",
};
const toneBorder: Record<StatTone, string> = {
neutral: "",
accent: "border-accent/25",
"accent-alt": "border-accent-alt/25",
};
const toneValue: Record<StatTone, string> = {
neutral: "text-ink",
accent: "text-accent",
"accent-alt": "text-accent-alt",
};
export const StatCard = React.forwardRef<HTMLDivElement, StatCardProps>(
(
{
className,
label,
value,
unit,
note,
icon,
trend,
size = "md",
featured,
tone = "neutral",
surface = "elevated",
spark,
...props
},
ref,
) => (
<Card
ref={ref}
highlight="wash"
surface={surface}
className={cn(toneBorder[tone], className)}
{...props}
>
<CardBody
className={cn(featured ? "p-6" : size === "sm" ? "p-4" : "p-5")}
>
<div className="flex items-start justify-between gap-3">
<span className="pt-0.5 font-mono text-[0.65rem] font-medium uppercase leading-none tracking-[0.16em] text-muted">
{label}
</span>
{icon != null && (
<span
aria-hidden="true"
className="-mt-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border bg-surface-2 text-muted [&_svg]:h-3.5 [&_svg]:w-3.5"
>
{icon}
</span>
)}
</div>
<div
className={cn(
"flex items-baseline gap-1.5",
featured ? "mt-4" : size === "sm" ? "mt-2" : "mt-3",
)}
>
<span
className={cn(
"font-mono font-semibold leading-none tabular-nums tracking-tight",
toneValue[tone],
featured
? "text-[clamp(2.5rem,5vw,3.5rem)]"
: size === "sm"
? "text-2xl"
: "text-[2rem]",
)}
>
{value}
</span>
{unit != null && (
<span className="font-mono text-sm text-muted">{unit}</span>
)}
</div>
{(note != null || trend != null || spark != null) && (
<div className="mt-3 flex items-center justify-between gap-3">
{(note != null || trend != null) && (
<span
className={cn(
"inline-flex items-center gap-1 font-mono text-[0.7rem] tabular-nums",
trend != null ? trendTone[trend] : "text-muted",
)}
>
{trend != null && (
<span aria-hidden="true">{trendGlyph[trend]}</span>
)}
{note}
</span>
)}
{spark != null && (
<div className="min-w-0 flex-1 text-accent [&>*]:w-full">
{spark}
</div>
)}
</div>
)}
</CardBody>
</Card>
),
);
StatCard.displayName = "StatCard";