EmpireUI
Get Pro
← Blog8 min read#tailwind#stats#kpi

Stat Card in Tailwind: KPI Metrics, Delta Indicator, Sparkline

Build a production-ready stat card with Tailwind CSS — KPI values, delta indicators, trend sparklines, and skeleton loaders. No chart library needed.

Dashboard stat cards showing KPI metrics and trend sparklines

Why Stat Cards Are Harder Than They Look

The humble stat card. You see it on every dashboard — a number, a label, maybe an arrow. Looks simple. Then you actually try to build one that handles real data: negative deltas, loading states, overflow numbers, sparklines that don't break the layout when the container shrinks to 280px. Suddenly it's not so trivial.

Most tutorials give you a div with a big number and call it a day. What you actually need in production is a component that communicates change, not just state. Your users don't care that revenue is $84,200 in isolation — they care whether that's up or down from last week and by how much. The delta indicator is the whole point.

Honestly, the hardest part isn't the CSS. It's thinking through the data contract upfront — what props does this component accept, what does it do when the sparkline data is empty, what happens when the value string is 7 characters long vs 14. Get those decisions right and the Tailwind classes follow naturally.

This guide builds a complete stat card from scratch using Tailwind CSS v3.4. You'll end up with something ready to drop into any React dashboard, with optional SVG sparklines that don't pull in recharts or any other chart library.

The Base Card Structure

Start with the shell. You want a card that's flex-column, handles variable-height content without jumping, and works across both light and dark mode from day one. Skipping dark mode support in 2026 is just leaving work for later-you.

Here's the base markup with Tailwind classes:

function StatCard({
  label,
  value,
  prefix = '',
  suffix = '',
}: {
  label: string;
  value: string | number;
  prefix?: string;
  suffix?: string;
}) {
  return (
    <div className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-md dark:bg-zinc-900/60">
      <p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
        {label}
      </p>
      <p className="mt-2 text-3xl font-bold tracking-tight text-zinc-900 dark:text-white">
        {prefix}
        {value}
        {suffix}
      </p>
    </div>
  );
}

That backdrop-blur-md on the card shell is doing real work here — if you're placing these over a gradient background (common in modern dashboards), the blur gives them depth without you needing to hard-code a background color. Worth noting: you'll need bg-white/5 to actually see the blur effect. Blur on a fully transparent element renders nothing.

The tracking-tight on the value is intentional. At text-3xl (30px) with default tracking, large numbers like $1,284,910 start to feel loose. Tighten it and the number reads as a single unit rather than individual characters floating apart.

Building the Delta Indicator

The delta tells the story. A green upward arrow with "+12.4%" and a red downward arrow with "-3.1%" communicate more than the raw KPI value in most cases. Get the logic right here and the rest of the component snaps into place.

function DeltaBadge({
  delta,
  deltaLabel = '',
}: {
  delta: number;
  deltaLabel?: string;
}) {
  const isPositive = delta >= 0;
  const isNeutral = delta === 0;

  const colorClasses = isNeutral
    ? 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400'
    : isPositive
    ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
    : 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400';

  const arrow = isNeutral ? '→' : isPositive ? '↑' : '↓';
  const sign = isPositive && delta > 0 ? '+' : '';

  return (
    <span
      className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${colorClasses}`}
    >
      <span aria-hidden="true">{arrow}</span>
      <span>
        {sign}{delta.toFixed(1)}%
        {deltaLabel && <span className="ml-1 font-normal opacity-70">{deltaLabel}</span>}
      </span>
    </span>
  );
}

In practice, you'll want to think carefully about what "positive" means for your specific KPI. Revenue up is good. Churn rate up is bad. Consider accepting an invertColors prop for metrics where increase is negative in meaning — your error rate going up 12% should render red even though the number is positive.

One more thing — always show the comparison period. "+12%" is almost meaningless without "vs last week" or "vs last month" next to it. That's what deltaLabel is for in the snippet above. Pass "vs last 7d" and you're done.

The rounded-full pill shape comes from Tailwind's design system defaults. You could go rounded-md for a more corporate SaaS look, or keep the pill for consumer-facing dashboards. Either reads fine at this size — the pill just signals "badge" more clearly to users.

Inline SVG Sparkline (No Chart Library Required)

You don't need recharts. You don't need victory, nivo, or chart.js just to render a tiny 80×32px trend line inside a stat card. An inline SVG path built from your data array is 15 lines of code and zero kilobytes of additional bundle.

function Sparkline({
  data,
  color = 'currentColor',
  height = 32,
  width = 80,
}: {
  data: number[];
  color?: string;
  height?: number;
  width?: number;
}) {
  if (!data || data.length < 2) return null;

  const min = Math.min(...data);
  const max = Math.max(...data);
  const range = max - min || 1;
  const step = width / (data.length - 1);

  const points = data
    .map((val, i) => {
      const x = i * step;
      const y = height - ((val - min) / range) * height;
      return `${x},${y}`;
    })
    .join(' ');

  return (
    <svg
      width={width}
      height={height}
      viewBox={`0 0 ${width} ${height}`}
      aria-hidden="true"
      className="overflow-visible"
    >
      <polyline
        fill="none"
        stroke={color}
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
        points={points}
      />
    </svg>
  );
}

That overflow-visible on the SVG matters. Without it, the last data point's stroke gets clipped at the svg boundary by exactly strokeWidth / 2 — 0.75px in this case — and you'll spend 20 minutes wondering why your line looks slightly off on the right edge.

Quick aside: aria-hidden="true" is correct here. The sparkline is decorative; the actual data is communicated by the value and delta text. Screen readers don't need to announce "polyline element" — that's noise. If you want sparkline data to be accessible, include a <title> element inside the SVG with a human-readable description.

For color, pass the Tailwind CSS variable directly or use stroke-emerald-500 as a class. The class approach is cleaner because it responds to dark mode automatically if you have appropriate color tokens set up. Check out the glassmorphism components for examples of how to handle color tokens across light/dark contexts elegantly.

Putting It All Together — Full Stat Card Component

Now wire everything up. The full component accepts a sparklineData prop and conditionally renders the sparkline only when data is present. It also handles a loading state with a skeleton — no external library, just Tailwind's animate-pulse.

interface StatCardProps {
  label: string;
  value: string;
  delta?: number;
  deltaLabel?: string;
  sparklineData?: number[];
  sparklineColor?: string;
  loading?: boolean;
  invertDeltaColor?: boolean;
}

function StatCard({
  label,
  value,
  delta,
  deltaLabel,
  sparklineData,
  sparklineColor = '#10b981',
  loading = false,
  invertDeltaColor = false,
}: StatCardProps) {
  const showDelta = typeof delta === 'number';
  const effectiveDelta = invertDeltaColor && showDelta ? -delta : delta;

  if (loading) {
    return (
      <div className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-md dark:bg-zinc-900/60">
        <div className="h-4 w-24 animate-pulse rounded bg-zinc-200 dark:bg-zinc-700" />
        <div className="mt-3 h-8 w-32 animate-pulse rounded bg-zinc-200 dark:bg-zinc-700" />
        <div className="mt-3 h-3 w-16 animate-pulse rounded bg-zinc-200 dark:bg-zinc-700" />
      </div>
    );
  }

  return (
    <div className="group rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-md transition-shadow hover:shadow-lg dark:bg-zinc-900/60">
      <div className="flex items-start justify-between">
        <p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">{label}</p>
        {sparklineData && (
          <Sparkline
            data={sparklineData}
            color={sparklineColor}
            width={80}
            height={32}
          />
        )}
      </div>

      <p className="mt-2 text-3xl font-bold tracking-tight text-zinc-900 dark:text-white">
        {value}
      </p>

      {showDelta && (
        <div className="mt-2">
          <DeltaBadge delta={effectiveDelta!} deltaLabel={deltaLabel} />
        </div>
      )}
    </div>
  );
}

Usage in a real dashboard grid:

<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
  <StatCard
    label="Monthly Revenue"
    value="$84,210"
    delta={12.4}
    deltaLabel="vs last month"
    sparklineData={[42, 55, 48, 70, 65, 80, 84]}
    sparklineColor="#10b981"
  />
  <StatCard
    label="Churn Rate"
    value="2.1%"
    delta={0.3}
    deltaLabel="vs last month"
    sparklineData={[1.8, 1.9, 2.0, 2.2, 2.1, 2.3, 2.1]}
    sparklineColor="#f87171"
    invertDeltaColor
  />
  <StatCard label="Active Users" value="12,840" loading />
  <StatCard
    label="Avg. Session"
    value="4m 32s"
    delta={-8.2}
    deltaLabel="vs last 7d"
    sparklineData={[5.1, 4.8, 4.9, 4.4, 4.6, 4.3, 4.5]}
    sparklineColor="#a78bfa"
  />
</div>

Look, the invertDeltaColor prop might seem like an edge case but you'll hit this the moment you show churn, error rate, support ticket volume, or any "lower is better" metric. Build it in from the start or you'll be going back to add it when a stakeholder notices the churn card is showing a green arrow for increasing churn.

If you're building a full dashboard layout with multiple stat card rows, take a look at the tailwind dashboard layout article — it covers the grid patterns and responsive breakpoints in more depth than fits here.

Styling Variants: Glass, Bordered, Filled

The base card above uses a glassmorphism approach — bg-white/5 backdrop-blur-md — which works great over gradient or image backgrounds. That's not always what you want. Here are two quick variants for different visual contexts.

For a clean SaaS-style bordered card (no blur, works on solid backgrounds):

// Bordered variant
<div className="rounded-xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">

For a filled/colored accent card that draws attention to a primary KPI:

// Filled primary variant
<div className="rounded-xl bg-violet-600 p-6 text-white shadow-md">
  {/* Use white text throughout, adjust opacity for secondary text */}
  <p className="text-sm font-medium text-violet-200">{label}</p>
  <p className="mt-2 text-3xl font-bold tracking-tight">{value}</p>
</div>

If you're using the glassmorphism variant and want to explore the generator for your specific background, the glassmorphism generator gives you live sliders for blur, opacity, and border — copy the CSS values back as Tailwind arbitrary values like backdrop-blur-[14px].

That said, don't put all four stat cards in the accent style. One filled card per row max — use it for the most important metric (usually revenue or active users) and keep the rest in the neutral bordered or glass style. Visual hierarchy only works if not everything is shouting.

Worth noting: if you're building a glassmorphism dashboard, the bg-white/5 backdrop-blur-md variant is the natural fit. The bordered variant looks out of place over blurred gradient backgrounds — the hard white border conflicts with the soft aesthetic.

Responsive Layout and Accessibility

A grid of four stat cards needs to collapse gracefully. On mobile, four columns at 280px each don't fit. The standard pattern is grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 — but think about what that means at the sm breakpoint (640px in Tailwind's default scale): you get two cards per row at 296px each after gap. That's tight but workable if your values don't exceed about 10 characters.

If you're showing values like $1,284,910.00 or 1,293,847 requests, consider truncating with a suffix — $1.28M, 1.29M req. You can do this in a formatter function rather than the component itself:

function formatStatValue(value: number, prefix = ''): string {
  if (value >= 1_000_000) return `${prefix}${(value / 1_000_000).toFixed(2)}M`;
  if (value >= 1_000) return `${prefix}${(value / 1_000).toFixed(1)}K`;
  return `${prefix}${value.toLocaleString()}`;
}

For accessibility: the stat card grid should use a role="list" container and role="listitem" on each card if they're semantically equivalent items (which KPI cards usually are). Each card should have its value and label readable in a logical order — the label before the value in the DOM even if your visual layout puts the label on top visually (which it already does here, so you're fine).

Don't forget the aria-hidden on the sparkline SVG and the delta arrow character. The text content of DeltaBadge+12.4% vs last month — is already machine-readable. You don't need the arrow glyph to be announced too. Screen reader users get the information they need; sighted users get the visual reinforcement. Check the WCAG accessibility guide if you need to verify contrast ratios on your specific color choices for the delta badges — emerald-700 on emerald-50 passes AA at this font size, but custom theme colors might not.

FAQ

Can I use this stat card component without React?

Yes — the Tailwind classes work in any framework. Convert the JSX to your templating language of choice and the CSS stays identical. The sparkline SVG is plain HTML.

How do I animate the number when data updates?

Wrap the value element in a Framer Motion component and use the animate prop to tween from the previous value to the new one. The useSpring hook from react-spring also works well for numeric counters.

What's the best way to handle real-time KPI updates?

Poll your API on an interval or use a WebSocket connection. Update state at the component level and let React re-render — don't try to animate the DOM directly. Skeleton loaders on initial load, then live updates once data arrives.

Do I need Tailwind v4 for any of these classes?

No, everything here runs on Tailwind v3.4. The backdrop-blur-md, bg-white/5, and animate-pulse classes are all available in v3. Nothing requires the v4 alpha.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Tailwind Dashboard Layout: Sidebar, Header and Content GridAdmin Dashboard in Tailwind: Full Layout With Charts and TablesGlassmorphism Stats Widget: KPI Cards With Frosted Glass and GlowGlassmorphism Dashboard: Full Admin UI with Frosted-Glass Cards