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

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/kpi-cards.tsx

    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>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.