Stat Tiles with Sparkline Trends

A row of four KPI tiles for the top of a dashboard. Each tile shows the metric name, the current value, what it was in the previous period, a change badge and a small trend line drawn in SVG from a plain array of numbers. A three-way switch changes the period and every tile updates. The tiles share one bordered panel with hairline dividers, and they stack to two columns on tablets and one column on phones.

Code

import { useState } from "react";

type Period = "7d" | "30d" | "90d";

const periods: { key: Period; label: string; compare: string }[] = [
  { key: "7d", label: "7 days", compare: "the 7 days before" },
  { key: "30d", label: "30 days", compare: "the 30 days before" },
  { key: "90d", label: "90 days", compare: "the 90 days before" },
];

// delta is the change in percent against the previous period. Set goodWhenDown for numbers you want to fall.
const stats = [
  {
    name: "Net revenue",
    goodWhenDown: false,
    data: {
      "7d": { value: "$18,420", previous: "$16,390", delta: 12.4, points: [22, 24, 21, 27, 26, 31, 34] },
      "30d": { value: "$74,905", previous: "$69,160", delta: 8.3, points: [40, 44, 41, 39, 46, 50, 48, 53, 51, 58, 61, 66] },
      "90d": { value: "$212,630", previous: "$178,080", delta: 19.4, points: [30, 33, 31, 38, 42, 40, 47, 52, 50, 58, 63, 71] },
    },
  },
  {
    name: "Orders",
    goodWhenDown: false,
    data: {
      "7d": { value: "412", previous: "391", delta: 5.4, points: [52, 61, 55, 58, 66, 63, 70] },
      "30d": { value: "1,684", previous: "1,722", delta: -2.2, points: [64, 66, 61, 63, 58, 60, 55, 59, 57, 54, 58, 56] },
      "90d": { value: "4,930", previous: "4,402", delta: 12.0, points: [40, 42, 47, 45, 51, 55, 53, 58, 62, 60, 66, 69] },
    },
  },
  {
    name: "Checkout conversion",
    goodWhenDown: false,
    data: {
      "7d": { value: "3.42%", previous: "3.61%", delta: -5.3, points: [38, 37, 39, 35, 34, 36, 33] },
      "30d": { value: "3.58%", previous: "3.31%", delta: 8.2, points: [30, 31, 29, 33, 34, 33, 36, 35, 38, 37, 39, 41] },
      "90d": { value: "3.37%", previous: "3.29%", delta: 2.4, points: [32, 30, 33, 31, 34, 32, 33, 35, 33, 36, 34, 36] },
    },
  },
  {
    name: "Refund rate",
    goodWhenDown: true,
    data: {
      "7d": { value: "1.8%", previous: "2.3%", delta: -21.7, points: [26, 25, 27, 22, 21, 19, 18] },
      "30d": { value: "2.1%", previous: "2.4%", delta: -12.5, points: [29, 28, 30, 27, 26, 27, 24, 25, 23, 22, 23, 21] },
      "90d": { value: "2.6%", previous: "2.2%", delta: 18.2, points: [20, 21, 20, 23, 22, 25, 24, 27, 26, 28, 27, 29] },
    },
  },
];

const W = 200;
const H = 56;

function sparkPath(points: number[]) {
  const min = Math.min(...points);
  const max = Math.max(...points);
  const span = max - min || 1;
  const coords = points.map((p, i) => [
    (i / (points.length - 1)) * (W - 8) + 4,
    H - 6 - ((p - min) / span) * (H - 14),
  ]);
  const line = coords.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" ");
  const last = coords[coords.length - 1];
  return { line, area: `${line} L${last[0].toFixed(1)} ${H} L4 ${H} Z`, last };
}

export default function StatTilesWithSparklineTrends() {
  const [period, setPeriod] = useState<Period>("30d");
  const active = periods.find((p) => p.key === period) ?? periods[1];

  return (
    <section className="bg-slate-50 px-4 py-12 text-slate-900 sm:px-6 sm:py-16">
      <div className="mx-auto max-w-6xl">
        <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <h2 className="text-xl font-semibold tracking-tight">Store performance</h2>
            <p className="mt-1 text-sm text-slate-600">Last {active.label}, compared with {active.compare}.</p>
          </div>
          <div role="group" aria-label="Time period" className="inline-flex self-start rounded-lg border border-slate-200 bg-white p-1 sm:self-auto">
            {periods.map((p) => (
              <button
                key={p.key}
                type="button"
                aria-pressed={period === p.key}
                onClick={() => setPeriod(p.key)}
                className={`h-10 rounded-md px-3.5 text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 ${period === p.key ? "bg-slate-900 text-white" : "text-slate-600 hover:text-slate-900"}`}
              >
                {p.label}
              </button>
            ))}
          </div>
        </div>

        <dl className="mt-6 grid gap-px overflow-hidden rounded-xl border border-slate-200 bg-slate-200 sm:grid-cols-2 lg:grid-cols-4">
          {stats.map((s, i) => {
            const d = s.data[period];
            const up = d.delta >= 0;
            const good = s.goodWhenDown ? !up : up;
            const { line, area, last } = sparkPath(d.points);
            return (
              <div key={s.name} className="flex flex-col bg-white p-5 sm:p-6">
                <div className="flex items-center justify-between gap-3">
                  <dt className="text-sm font-medium text-slate-600">{s.name}</dt>
                  <span className={`inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-semibold tabular-nums ${good ? "bg-emerald-50 text-emerald-700" : "bg-rose-50 text-rose-700"}`}>
                    <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={`h-3 w-3 ${up ? "" : "rotate-180"}`}>
                      <path d="M10 16V4M4.5 9.5 10 4l5.5 5.5" />
                    </svg>
                    <span className="sr-only">{up ? "Up" : "Down"}</span>
                    {Math.abs(d.delta).toFixed(1)}%
                  </span>
                </div>
                <dd className="mt-3">
                  <span className="block text-3xl font-semibold tracking-tight tabular-nums">{d.value}</span>
                  <span className="mt-1 block text-xs text-slate-500">was {d.previous}</span>
                </dd>
                <svg viewBox={`0 0 ${W} ${H}`} aria-hidden="true" className={`mt-5 h-auto w-full ${good ? "text-emerald-600" : "text-rose-600"}`}>
                  <defs>
                    <linearGradient id={`spark-fill-${i}`} x1="0" y1="0" x2="0" y2="1">
                      <stop offset="0" stopColor="currentColor" stopOpacity="0.18" />
                      <stop offset="1" stopColor="currentColor" stopOpacity="0" />
                    </linearGradient>
                  </defs>
                  <path d={area} fill={`url(#spark-fill-${i})`} />
                  <path d={line} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
                  <circle cx={last[0]} cy={last[1]} r="3" fill="currentColor" stroke="white" strokeWidth="1.5" />
                </svg>
              </div>
            );
          })}
        </dl>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/stat-tiles-with-sparkline-trends.json

When to use it

How to customize it

Accessibility

Questions

How is the sparkline drawn without a chart library?

The `sparkPath` function scales the numbers in `points` between the lowest and highest value and joins them into an SVG path. A second path closes the shape to the bottom edge for the soft fill, and a circle marks the latest point.

How do I connect the tiles to real data?

Replace the `stats` constant with data from your API in the same shape. Keep `points` as an array of numbers in time order, oldest first. Any length from about five points upward draws well.

Why is a falling refund rate shown in green?

Colour should say whether a change is good, not only which way it went. The arrow shows direction and the colour shows the verdict, which is why the refund tile has `goodWhenDown` set.

Can I show more or fewer than four tiles?

Yes. Add or remove entries in `stats` and change `lg:grid-cols-4` to match the count, for example `lg:grid-cols-3` for three tiles.

Does this work in a dark dashboard?

Yes. Change the section to `bg-slate-950 text-white`, the tiles to `bg-slate-900`, the panel border and gap colour to `slate-800`, and the grey text to `text-slate-400`.

Related: App Shell with Sidebar and Top Bar, Product Grid with Filter Chips, Pricing Table with Monthly and Yearly Toggle, Button Set: Primary, Secondary, Ghost, Icon and Loading.

More dashboard or all free UI components. Start for free to have a full site built around it.

More in Dashboard

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap