A big-number KPI tile with a filled sparkline, signed delta, and threshold-driven accent color.
npx shadcn@latest add @paragon/metric-tile"use client";
import * as React from "react";
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
import { cn } from "@/lib/utils";
export type MetricThreshold = "ok" | "warn" | "critical";
export interface MetricTileProps extends React.ComponentProps<"div"> {
label: string;
value: string;
unit?: string;
/** Signed percentage delta vs the prior window, e.g. -4.2. */
delta?: number;
/** For delta color: is a rise good or bad? Latency rising is bad. */
deltaGoodDirection?: "up" | "down";
/** Sparkline series, oldest → newest. Normalized internally. */
series?: number[];
threshold?: MetricThreshold;
}
const THRESHOLD_VAR: Record<MetricThreshold, string> = {
ok: "var(--color-success)",
warn: "var(--color-warning)",
critical: "var(--color-destructive)",
};
function Sparkline({ series, color }: { series: number[]; color: string }) {
const w = 100;
const h = 32;
const min = Math.min(...series);
const max = Math.max(...series);
const span = max - min || 1;
const step = series.length > 1 ? w / (series.length - 1) : w;
const pts = series.map((v, i) => {
const x = i * step;
const y = h - ((v - min) / span) * (h - 4) - 2;
return [x, y] as const;
});
const line = pts.map(([x, y], i) => `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`).join(" ");
const area = `${line} L ${w} ${h} L 0 ${h} Z`;
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
return (
<svg
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
className="h-8 w-full"
aria-hidden
>
<defs>
<linearGradient id={`spark-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.18" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<path d={area} fill={`url(#spark-${id})`} />
<path
d={line}
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
const DEFAULT_SERIES = [42, 44, 40, 48, 52, 49, 55, 58, 54, 61, 59, 64];
export function MetricTile({
label,
value,
unit,
delta,
deltaGoodDirection = "up",
series = DEFAULT_SERIES,
threshold = "ok",
className,
...props
}: MetricTileProps) {
const color = THRESHOLD_VAR[threshold];
const hasDelta = typeof delta === "number";
const rising = hasDelta && delta! >= 0;
const good = hasDelta && (rising ? deltaGoodDirection === "up" : deltaGoodDirection === "down");
const deltaColor = good ? "var(--color-success)" : "var(--color-destructive)";
return (
<div
className={cn(
"flex w-full max-w-[220px] flex-col gap-2 rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center gap-2">
<span
aria-hidden
className="size-1.5 rounded-full"
style={{ background: color }}
/>
<span className="truncate text-xs font-medium text-muted-foreground">
{label}
</span>
</div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-semibold tabular-nums leading-none tracking-tight">
{value}
</span>
{unit && (
<span className="text-sm font-medium text-muted-foreground">{unit}</span>
)}
</div>
{hasDelta && (
<div
className="flex items-center gap-0.5 text-xs font-medium tabular-nums"
style={{ color: deltaColor }}
>
{rising ? (
<ArrowUpRight className="size-3.5" />
) : (
<ArrowDownRight className="size-3.5" />
)}
{Math.abs(delta!).toFixed(1)}%
<span className="ml-1 font-normal text-muted-foreground">vs 24h</span>
</div>
)}
<Sparkline series={series} color={color} />
</div>
);
}