A metric card with value, semantic delta badge, and a computed SVG sparkline that draws in on first view.
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-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>
);
}