Cards

Stat Card

A metric card with value, delta badge, and sparkline placeholder that deepens its border on hover.

Install

npx shadcn@latest add @paragon/stat-card

stat-card.tsx

"use client";

import * as React from "react";
import { ArrowDownRight, ArrowUpRight, Minus } from "lucide-react";
import { cn } from "@/lib/utils";

type Trend = "up" | "down" | "neutral";

const trendBadgeStyles: Record<Trend, string> = {
  up: "bg-emerald-600/10 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-400",
  down: "bg-red-600/10 text-red-700 dark:bg-red-400/10 dark:text-red-400",
  neutral: "bg-muted text-muted-foreground",
};

const trendStrokeStyles: Record<Trend, string> = {
  up: "text-emerald-600 dark:text-emerald-400",
  down: "text-red-600 dark:text-red-400",
  neutral: "text-muted-foreground",
};

const trendIcons: Record<Trend, React.ComponentType<{ className?: string }>> = {
  up: ArrowUpRight,
  down: ArrowDownRight,
  neutral: Minus,
};

export interface StatCardProps extends React.ComponentProps<"div"> {
  /** Metric name shown above the value. */
  label?: string;
  /** Pre-formatted value — rendered with tabular-nums. */
  value?: string;
  /** Pre-formatted change for the badge, e.g. "+12.4%". */
  delta?: string;
  /** Semantic direction — colors the delta badge and sparkline in both themes. */
  trend?: Trend;
  /** Sparkline series (needs ≥ 2 points). Omit to render the default series. */
  data?: number[];
}

const SPARK_WIDTH = 72;
const SPARK_HEIGHT = 28;
const SPARK_PAD = 2;

function buildSparkPaths(data: number[]) {
  const min = Math.min(...data);
  const max = Math.max(...data);
  const range = max - min;
  const step = SPARK_WIDTH / (data.length - 1);
  const drawable = SPARK_HEIGHT - SPARK_PAD * 2;

  const line = data
    .map((v, i) => {
      const norm = range === 0 ? 0.5 : (v - min) / range;
      const x = (i * step).toFixed(2);
      const y = (SPARK_PAD + (1 - norm) * drawable).toFixed(2);
      return `${i === 0 ? "M" : "L"}${x} ${y}`;
    })
    .join(" ");
  const area = `${line} L${SPARK_WIDTH} ${SPARK_HEIGHT} L0 ${SPARK_HEIGHT} Z`;
  return { line, area };
}

/**
 * A metric card: label, tabular-nums value, semantic delta badge, and a
 * hand-drawn SVG sparkline. The depth border deepens on hover via
 * shadow-border → shadow-border-hover on an enumerated box-shadow transition.
 */
export function StatCard({
  label = "Monthly recurring revenue",
  value = "$84,240",
  delta = "+12.4%",
  trend = "up",
  data = [42, 44, 43, 47, 49, 48, 52, 55, 54, 58, 61, 65],
  className,
  ...props
}: StatCardProps) {
  const gradientId = `stat-spark-${React.useId().replace(/[^a-zA-Z0-9-]/g, "")}`;
  const TrendIcon = trendIcons[trend];
  const paths = data.length >= 2 ? buildSparkPaths(data) : null;

  return (
    <div
      data-slot="stat-card"
      className={cn(
        "rounded-xl bg-card p-5 text-card-foreground shadow-border transition-[box-shadow] duration-150 ease-out hover:shadow-border-hover",
        className,
      )}
      {...props}
    >
      <div className="flex items-start justify-between gap-3">
        <p className="text-sm text-muted-foreground">{label}</p>
        {delta && (
          <span
            className={cn(
              "inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums",
              trendBadgeStyles[trend],
            )}
          >
            <TrendIcon aria-hidden className="size-3 shrink-0" />
            {delta}
          </span>
        )}
      </div>
      <div className="mt-3 flex items-end justify-between gap-4">
        <p className="text-2xl font-semibold tracking-tight tabular-nums">
          {value}
        </p>
        {paths && (
          <svg
            aria-hidden
            viewBox={`0 0 ${SPARK_WIDTH} ${SPARK_HEIGHT}`}
            fill="none"
            className={cn(
              "pointer-events-none h-7 w-[4.5rem] shrink-0",
              trendStrokeStyles[trend],
            )}
          >
            <defs>
              <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
                <stop offset="0%" stopColor="currentColor" stopOpacity="0.16" />
                <stop offset="100%" stopColor="currentColor" stopOpacity="0" />
              </linearGradient>
            </defs>
            <path d={paths.area} fill={`url(#${gradientId})`} />
            <path
              d={paths.line}
              stroke="currentColor"
              strokeWidth="1.5"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        )}
      </div>
    </div>
  );
}