A metric card with value, delta badge, and sparkline placeholder that deepens its border on hover.
npx shadcn@latest add @paragon/stat-card"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>
);
}