Skip to content
All Taste components

Dashboard widgets

KPI row

Four headline numbers with their change since last month and a small trend line.

4 stats · up/down trend · good/bad tone · sparkline

Revenue12.4%
₹8,42,300
vs last month
New customers8.1%
184
vs last month
Avg. order value2.3%
₹2,310
vs last month
Refund rate0.4 pt
1.2%
vs last month
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
import { cn } from "@/lib/utils";

export interface Kpi {
  label: string;
  value: string;
  /** Change since the last period, e.g. 12.4 or -2.3. */
  change: number;
  /** Shown after the number: "%" or " pt". */
  unit?: string;
  /** True when a falling number is good news (refunds, churn). */
  lowerIsBetter?: boolean;
  series: number[];
}

const sample: Kpi[] = [
  { label: "Revenue", value: "₹8,42,300", change: 12.4, series: [22, 25, 24, 28, 27, 31, 30, 34, 33, 37, 39, 42] },
  { label: "New customers", value: "184", change: 8.1, series: [12, 14, 13, 13, 16, 15, 18, 17, 19, 18, 21, 22] },
  { label: "Avg. order value", value: "₹2,310", change: -2.3, series: [30, 31, 29, 30, 28, 29, 27, 28, 27, 26, 27, 26] },
  { label: "Refund rate", value: "1.2%", change: -0.4, unit: " pt", lowerIsBetter: true, series: [9, 8, 9, 7, 8, 6, 7, 6, 5, 6, 5, 4] },
];

function Sparkline({ data }: { data: number[] }) {
  const lo = Math.min(...data);
  const hi = Math.max(...data);
  const points = data
    .map((v, i) => (i / (data.length - 1)) * 100 + "," + (92 - ((v - lo) / (hi - lo || 1)) * 84))
    .join(" ");
  return (
    <svg viewBox="0 0 100 100" preserveAspectRatio="none" className="mt-3 h-8 w-full overflow-visible text-primary" aria-hidden>
      <polyline points={points} fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
    </svg>
  );
}

export function KpiRow({ items = sample, period = "vs last month" }: { items?: Kpi[]; period?: string }) {
  return (
    <div className="grid w-full gap-3 [grid-template-columns:repeat(auto-fit,minmax(150px,1fr))]">
      {items.map((k) => {
        const up = k.change >= 0;
        const good = k.lowerIsBetter ? !up : up;
        const Icon = up ? ArrowUpRight : ArrowDownRight;
        return (
          <div key={k.label} className="min-w-0 rounded-lg border border-border bg-bg px-4 py-3.5">
            <div className="flex items-center justify-between gap-2">
              <p className="text-[13px] text-fg-muted">{k.label}</p>
              <span
                className={cn(
                  "inline-flex h-5 items-center gap-0.5 rounded-full px-1.5 text-xs font-medium tabular-nums",
                  good ? "bg-success-soft text-success-fg" : "bg-danger-soft text-danger-fg",
                )}
              >
                <Icon className="size-3" aria-hidden />
                <span className="sr-only">{up ? "Up" : "Down"}</span>
                {Math.abs(k.change)}{k.unit ?? "%"}
              </span>
            </div>
            <p className="mt-1 text-2xl font-medium tracking-tight tabular-nums text-fg">{k.value}</p>
            <p className="text-[12.5px] text-fg-subtle">{period}</p>
            <Sparkline data={k.series} />
          </div>
        );
      })}
    </div>
  );
}

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.