Stat Card
Cards

Stat Card

A metric card with value, semantic delta badge, and a computed SVG sparkline that draws in on first view.

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-success/10 text-success",
  down: "bg-destructive/10 text-destructive",
  neutral: "bg-muted text-muted-foreground",
};

const trendStrokeStyles: Record<Trend, string> = {
  up: "text-success",
  down: "text-destructive",
  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
 * computed SVG sparkline. On first entering the viewport the sparkline
 * wipes in left-to-right via a clip-path transition (transform-free, runs
 * once, instant under prefers-reduced-motion); the depth border deepens on
 * hover via shadow-border → shadow-border-hover.
 */
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;

  const svgRef = React.useRef<SVGSVGElement>(null);
  const [drawn, setDrawn] = React.useState(false);

  React.useEffect(() => {
    const node = svgRef.current;
    if (!node) {
      setDrawn(true);
      return;
    }
    if (typeof IntersectionObserver === "undefined") {
      setDrawn(true);
      return;
    }
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setDrawn(true);
          observer.disconnect();
        }
      },
      { rootMargin: "0px 0px -32px 0px" },
    );
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  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
            ref={svgRef}
            aria-hidden
            viewBox={`0 0 ${SPARK_WIDTH} ${SPARK_HEIGHT}`}
            fill="none"
            className={cn(
              "pointer-events-none h-7 w-[4.5rem] shrink-0 transition-[clip-path] duration-(--duration-slow) ease-(--ease-out) motion-reduce:transition-none",
              trendStrokeStyles[trend],
            )}
            style={{
              // Left-to-right wipe; -2px slack keeps round caps unclipped.
              clipPath: drawn
                ? "inset(-2px -2px -2px -2px)"
                : "inset(-2px 100% -2px -2px)",
            }}
          >
            <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>
  );
}