KPI Cards
Metric cards with trend arrows and self-drawing sparklines.
Revenue
$84.2k
12.4% vs last month
Active users
24,810
8.1% vs last month
Churn rate
1.9%
0.6% vs last month
components/ui/kpi-cards.tsx
"use client";
import { motion } from "motion/react";
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
type Kpi = { label: string; value: string; delta: number; series: number[] };
const defaultKpis: Kpi[] = [
{ label: "Revenue", value: "$84.2k", delta: 12.4, series: [12, 18, 15, 22, 20, 28, 26, 34] },
{ label: "Active users", value: "24,810", delta: 8.1, series: [20, 22, 21, 25, 27, 26, 30, 33] },
{ label: "Churn rate", value: "1.9%", delta: -0.6, series: [30, 28, 29, 25, 24, 22, 21, 19] },
];
function Sparkline({ series, positive }: { series: number[]; positive: boolean }) {
const w = 120;
const h = 40;
const min = Math.min(...series);
const max = Math.max(...series);
const pts = series.map((v, i) => [(i / (series.length - 1)) * w, h - ((v - min) / (max - min || 1)) * (h - 4) - 2]);
const d = pts.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" ");
const color = positive ? "#16a34a" : "#e11d48";
return (
<svg viewBox={`0 0 ${w} ${h}`} className="h-10 w-28" aria-hidden>
<motion.path d={d} fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ duration: 1.2, ease: "easeInOut" }} />
</svg>
);
}
export function KpiCards({ kpis = defaultKpis }: { kpis?: Kpi[] }) {
return (
<div className="grid gap-4 sm:grid-cols-3">
{kpis.map((k, i) => {
const good = k.label.toLowerCase().includes("churn") ? k.delta < 0 : k.delta > 0;
return (
<motion.div
key={k.label}
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08 }}
className="rounded-2xl border border-zinc-200 bg-white p-5"
>
<p className="text-sm text-zinc-500">{k.label}</p>
<div className="mt-2 flex items-end justify-between gap-3">
<div>
<p className="text-2xl font-semibold tracking-tight text-zinc-900">{k.value}</p>
<p className={`mt-1 inline-flex items-center gap-0.5 text-xs font-medium ${good ? "text-emerald-600" : "text-rose-600"}`}>
{k.delta > 0 ? <ArrowUpRight className="size-3.5" /> : <ArrowDownRight className="size-3.5" />}
{Math.abs(k.delta)}% vs last month
</p>
</div>
<Sparkline series={k.series} positive={good} />
</div>
</motion.div>
);
})}
</div>
);
}
export default function Demo() {
return (
<div className="w-full max-w-4xl">
<KpiCards />
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/kpi-cards.tsxcomponents/ui/kpi-cards.tsx"use client"; import { motion } from "motion/react"; import { ArrowDownRight, ArrowUpRight } from "lucide-react"; type Kpi = { label: string; value: string; delta: number; series: number[] }; const defaultKpis: Kpi[] = [ { label: "Revenue", value: "$84.2k", delta: 12.4, series: [12, 18, 15, 22, 20, 28, 26, 34] }, { label: "Active users", value: "24,810", delta: 8.1, series: [20, 22, 21, 25, 27, 26, 30, 33] }, { label: "Churn rate", value: "1.9%", delta: -0.6, series: [30, 28, 29, 25, 24, 22, 21, 19] }, ]; function Sparkline({ series, positive }: { series: number[]; positive: boolean }) { const w = 120; const h = 40; const min = Math.min(...series); const max = Math.max(...series); const pts = series.map((v, i) => [(i / (series.length - 1)) * w, h - ((v - min) / (max - min || 1)) * (h - 4) - 2]); const d = pts.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" "); const color = positive ? "#16a34a" : "#e11d48"; return ( <svg viewBox={`0 0 ${w} ${h}`} className="h-10 w-28" aria-hidden> <motion.path d={d} fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" initial={{ pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true }} transition={{ duration: 1.2, ease: "easeInOut" }} /> </svg> ); } export function KpiCards({ kpis = defaultKpis }: { kpis?: Kpi[] }) { return ( <div className="grid gap-4 sm:grid-cols-3"> {kpis.map((k, i) => { const good = k.label.toLowerCase().includes("churn") ? k.delta < 0 : k.delta > 0; return ( <motion.div key={k.label} initial={{ opacity: 0, y: 12 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ delay: i * 0.08 }} className="rounded-2xl border border-zinc-200 bg-white p-5" > <p className="text-sm text-zinc-500">{k.label}</p> <div className="mt-2 flex items-end justify-between gap-3"> <div> <p className="text-2xl font-semibold tracking-tight text-zinc-900">{k.value}</p> <p className={`mt-1 inline-flex items-center gap-0.5 text-xs font-medium ${good ? "text-emerald-600" : "text-rose-600"}`}> {k.delta > 0 ? <ArrowUpRight className="size-3.5" /> : <ArrowDownRight className="size-3.5" />} {Math.abs(k.delta)}% vs last month </p> </div> <Sparkline series={k.series} positive={good} /> </div> </motion.div> ); })} </div> ); } export default function Demo() { return ( <div className="w-full max-w-4xl"> <KpiCards /> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.