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

layer

core / patterns

intensity

structures · organizes a region, stays out of the content

composition

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 link

a11y

the icon and trend glyph are aria-hidden · the trend meaning is duplicated by the note text, never color alonejest-axe

dependencies

Card from the same package
live 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

proptypedefaultnotes
labelReact.ReactNodethe uppercase metric label at the top.
valueReact.ReactNodethe figure. mono, tabular, the loudest thing in the tile.
unitReact.ReactNodequiet suffix beside the value, e.g. ms, %.
noteReact.ReactNodethe footnote beside the trend glyph.
iconReact.ReactNodedecorative 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.
featuredbooleanfalseblows 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.
surfaceCardSurface"elevated"how the tile sits on the page. passed through to Card.
sparkReact.ReactNodeslot for a sparkline or mini-chart, stretched to fill.

get it

npx shadcn add https://usva.build/r/stat-card.jsoncopy
source · components/ui/stat-card.tsxexactly what this command copiescopy
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";