# Shadcn KPI Cards: Deltas, Sparklines and Goal Bars

> Build shadcn KPI cards that tell you if a number is good: a count-up value, a delta colored by meaning, a sparkline, a goal bar and a two-column phone grid.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Nov 6, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/shadcn-kpi-cards

## TL;DR

A good shadcn KPI card tells the reader at a glance whether a number is good, which takes five parts: a clear label, a formatted number, a delta colored by meaning (a drop in churn is green), a sparkline for the shape of the period and a goal bar for the target. shadcn/ui has no KPI card component, and its dashboard-01 block ships static cards with hardcoded values and an outline badge that looks the same for gains and losses. The Wingo UI Stat Card, Trend Badge, Sparkline and KPI Dashboard (all Pro) handle formatting, meaning, count-up and the two-column phone grid through props.

You open a dashboard, see "$128,450" in a tile and still do not know if the month went well. That is the gap most shadcn KPI cards leave open: a number, a label and maybe a badge, with no sense of direction, shape or target. This guide shows what a KPI card needs to answer "is this good?", what the official shadcn block gives you, and how to build shadcn stat cards with the Wingo UI [Stat Card](https://wingo-ui.com/components/stat-card), [Trend Badge](https://wingo-ui.com/components/trend-badge) and [Sparkline](https://wingo-ui.com/components/sparkline). Then we put them into the full [KPI Dashboard](https://wingo-ui.com/components/kpi-dashboard) block and look at how its grid collapses on phones.

## What should a KPI card show?

A KPI card earns its place when someone can read it in two seconds and know what to do next. That takes five parts, each answering a smaller question:

1. **The label and an explanation.** What is this number? "Revenue" is fine; "Net revenue, before tax" behind an info icon is better, because two people on the same team often mean different things by the same word.
2. **The number, formatted for the reader.** `$128,450`, `2.1%`, `18 days`, `128.5K`. The format belongs to the metric, and the locale belongs to the reader.
3. **The change and its meaning.** `+12.4% vs. last month`, green when the change is good, red when it is bad. The color has to follow the meaning, not the sign: a drop in churn is good news.
4. **The shape of the period.** A sparkline shows whether a +12% month was a steady climb or one big invoice on the last day.
5. **The target.** A goal bar ("68% of goal") turns a number into progress.

Most cards stop after the first two or three. The last two are what make a row of React KPI cards useful instead of decorative.

## What do the shadcn dashboard cards give you?

shadcn/ui has no KPI or stat card component (checked November 2026). The closest thing is the `SectionCards` file inside the `dashboard-01` block ([source on GitHub](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/blocks/dashboard-01/components/section-cards.tsx), read November 6, 2026). It composes `Card`, `CardHeader`, `CardDescription`, `CardTitle`, `CardAction`, `CardFooter` and `Badge`, and it is a good starting point for a shadcn analytics dashboard:

- Each card has a label, a hardcoded value like `$1,250.00`, an outline `Badge` with a trend icon and a text like `+12.5%`, and a two-line footer ("Down 20% this period", "Acquisition needs attention").
- The badge looks the same for an increase and a decrease. Only the icon changes, so "-20%" on New Customers is not visually marked as bad.
- There is no sparkline, no goal and no count-up. Values are strings in JSX, so formatting is up to you.
- The grid is one column by default, two from the `@xl` container width and four from `@5xl`.

That is the shadcn way: a pattern you copy and own, and community block collections follow the same copy-and-own model. It works well for a fixed set of four tiles. Once the numbers come from an API, you end up writing the formatting, the good-or-bad color per metric and the loading state yourself, in every card. A component with props moves that logic into one place.

## How do I build a KPI card with Stat Card?

Install `stat-card`, then pass a numeric `value`, a `format`, a `delta`, the sparkline data and a `goal`: one element covers all five parts. The CLI brings its dependencies along (the counter, progress bar, tooltip and number helpers):

```bash
npx wingo-ui@latest add stat-card
```

`value` is a number, `format` decides how it reads, `delta` is the change in `deltaFormat` units (percent by default), and `goal` adds the progress bar:

```tsx
import { Wallet } from "lucide-react";
import { StatCard } from "@/components/ui/stat-card";

const daily = [3900, 4100, 3800, 4500, 4700, 4200, 5100, 5300, 4900, 5600, 5800, 6100];

export function RevenueCard() {
  return (
    <StatCard
      label="Revenue"
      info="Invoices issued, before tax"
      icon={<Wallet />}
      period="Sept. 2026"
      value={128450}
      format="currency"
      currency="USD"
      delta={12.4}
      deltaLabel="vs. last month"
      sparkline={daily}
      goal={190000}
      locale="en-US"
    />
  );
}
```

> Live demo (Stat Card): Press Simulate a new day: a new day joins the sparkline and the value and the delta count from the numbers on screen to the new ones. Try it at [Stat Card](https://wingo-ui.com/components/stat-card) and install it with `npx wingo-ui@latest add stat-card`.

### Does the count-up hurt SEO or screen readers?

No, because the animation only runs on the client. The server HTML and the first paint already contain the final number, so a crawler or a slow phone sees `$128,450`, not `$0`. The number counts up from zero once, the first time the card scrolls into view (900ms by default, set with `duration`), and later changes count from the number on screen. Screen readers hear one sentence with the final value and the change, such as "$128,450, up 12.4% vs. last month", never the numbers in between. Under reduced motion the numbers and charts appear final at once. For live numbers that tick, `animation="roll"` uses an odometer instead, and `animation="none"` turns it off.

### How does the delta know good from bad?

The arrow follows the sign of `delta`; the color follows the meaning. By default a rise is green and a fall is red. Add `invertTrend` for metrics where down is good, like costs, churn or response time: a drop turns green and the arrow still points down. The `tone` prop colors the icon tile, the sparkline and the goal bar, but never the delta, so a brand-orange card cannot accidentally make bad news look neutral.

The same logic is a standalone component, `TrendBadge`, for table cells, leaderboards and your own cards:

```tsx
"use client";

import { TrendBadge } from "@/components/ui/trend-badge";

export function ChangeCells() {
  return (
    <>
      <TrendBadge value={12.4} label="vs. last month" locale="en-US" />
      <TrendBadge value={-0.4} invertTrend formatValue={(v) => `${v < 0 ? "−" : "+"}${Math.abs(v)} pts`} />
    </>
  );
}
```

The file needs `"use client"` because `formatValue` is a function, and functions cannot cross from a Server Component into a Client Component as props.

> Live demo (Trend badge): Press Change to cycle the value: with invertTrend on, a rise turns red and a drop green, while the arrow keeps pointing the real way. Try it at [Trend badge](https://wingo-ui.com/components/trend-badge) and install it with `npx wingo-ui@latest add trend-badge`.

### When do I use the standalone Sparkline?

Use the standalone one when a reader needs to inspect the points, or when the chart lives outside a card. Stat Card has a built-in sparkline (`sparkline` for the data, `sparklineType` for `line`, `area` or `bar`), and `stat-card.tsx` also exports that chart as `Sparkline` for simple cases. The separate [Sparkline](https://wingo-ui.com/components/sparkline) component goes further: win / loss ticks (`type="win-loss"`), min and max markers (`showMinMax`), a dashed `reference` line at the first value, the average or zero, `trendColor` to color by direction, and `interactive` mode where you hover, drag a finger or use the arrow keys to read each point. Use it in table rows, or pass it to a card through the `chart` prop when you need those extras:

```tsx
import { Sparkline } from "@/components/ui/sparkline";

export function RevenueTrend() {
  return (
    <Sparkline
      data={[8200, 8650, 8400, 9100, 9480, 10150, 10600, 11420, 12480]}
      valueFormat="currency"
      label="Revenue, last 9 weeks"
      trendColor
      interactive
      locale="en-US"
    />
  );
}
```

Both files export a component named `Sparkline`, so import the standalone one from `@/components/ui/sparkline` and alias it if a file needs both.

## How do I lay out a row of React KPI cards?

Wrap the cards in `StatGroup`. It is a list (`role="list"`) with responsive columns: two on phones and four from 1024px by default, changed with `columns={{ base, sm, lg }}`. The tiles count up 60ms apart (turn it off with `stagger={false}`), and `variant="joined"` draws one panel with hairlines between the tiles instead of separate cards.

```tsx
"use client";

import { Clock, Repeat, UserMinus, Users } from "lucide-react";
import { StatCard, StatGroup } from "@/components/ui/stat-card";

export function SaasKpis({ mrr, active }: { mrr: number[]; active: number[] }) {
  return (
    <StatGroup variant="joined">
      <StatCard label="MRR" icon={<Repeat />} value={42800} format="currency" delta={6.2} sparkline={mrr} goal={50000} locale="en-US" />
      <StatCard label="Active users" icon={<Users />} value={3214} delta={4.8} sparkline={active} locale="en-US" />
      <StatCard
        label="Churn"
        icon={<UserMinus />}
        value={2.1}
        format="percent"
        delta={-0.4}
        formatDelta={(d) => `${d} pts`}
        invertTrend
        locale="en-US"
      />
      <StatCard
        label="First response time"
        icon={<Clock />}
        value={4 * 3600}
        format="duration"
        delta={-1800}
        deltaFormat="duration"
        invertTrend
        locale="en-US"
      />
    </StatGroup>
  );
}
```

Three details in that code matter. The file is a Client Component because `formatDelta` is a function prop. Churn uses `formatDelta` because a change in a percentage is percentage points, not percent: going from 2.5% to 2.1% is "−0.4 pts", and printing "−0.4%" would be wrong. Durations take seconds, so `format="duration"` prints `4 hr` and the delta prints `30 min`. Pass `loading` while the data is on its way and each card shows skeletons at the real sizes, so nothing jumps when the numbers land.

## What does a full React KPI dashboard look like?

A full dashboard puts the KPI tiles on top and the widgets under them, and the [KPI Dashboard](https://wingo-ui.com/components/kpi-dashboard) block ships that whole screen: revenue against the previous period, revenue by month or city, sources, a funnel, top sellers, the latest invoices and an activity feed. The header has a period picker with presets and a compare switch that turns the dashed previous series and every delta on and off. Refresh and export buttons appear when you pass `onRefresh` and `onExport`.

```bash
npx wingo-ui@latest add kpi-dashboard
```

```tsx
"use client";

import { useState } from "react";
import { KpiDashboard, KPI_DASHBOARD_SAMPLE } from "@/components/ui/kpi-dashboard";
import type { DateRange } from "@/lib/date-utils";

export function SalesDashboard() {
  const [period, setPeriod] = useState<DateRange>({ from: new Date(2026, 8, 1), to: new Date(2026, 8, 30) });
  return (
    <KpiDashboard
      title="Dashboard"
      description="Northwind Software Inc. · Portland"
      data={KPI_DASHBOARD_SAMPLE}
      period={period}
      onPeriodChange={setPeriod}
      onRefresh={() => fetch("/api/dashboard")}
      widgetLinks={{ invoices: "/invoices", activity: "/activity" }}
      locale="en-US"
    />
  );
}
```

The block shows data; fetching it for a period is your app's job. `onRefresh` can return a promise: the button spins until it settles and then announces the update time. Each item in `data.kpis` is a `KpiItem` with the same ideas as Stat Card: `value`, `format`, `delta`, `deltaFormat` (including `points` for percentage points), `invertTrend`, `sparkline`, `goal`, `info` and `href`. In the sample, Outstanding is $31,650 with a +4.1% delta and `invertTrend`, so the rise in money owed shows red.

> Live demo (KPI Dashboard): Pick a period, flip the compare switch and watch the deltas and the dashed series go away, then narrow the window to see the KPI tiles go two per row. Try it at [KPI Dashboard](https://wingo-ui.com/components/kpi-dashboard) and install it with `npx wingo-ui@latest add kpi-dashboard`.

## How does the KPI grid collapse on phones?

The dashboard measures its own width with container queries, not the viewport, so a sidebar never squeezes it into a broken layout. With the default `kpiColumns={0}` (auto):

| Dashboard width | KPI tiles per row | Widgets |
|---|---|---|
| below 576px | 2, and an odd last tile stretches to the full row | one column |
| 576px to 671px | 3 (2 when there are exactly 4), leftover tiles stretch | one column |
| 672px to 1023px | 3 (2 for 4 tiles) | two columns |
| 1024px and up | every tile in one row, up to 6 (more wrap into rows) | two columns, three from 1152px |

The latest invoices switch from a table to cards whenever their widget is narrower than 672px. On a phone the header actions also fold: refresh and export move into one ⋯ button that opens an action sheet, and the period picker opens as a bottom sheet instead of a popover. `StatGroup` alone uses viewport breakpoints (two columns base, four from `lg`), and at the `md` size the values shrink so a currency figure still fits a 360px screen. Two per row is the right phone default: one per row turns four KPIs into a full screen of scrolling, and three leaves each tile about 100px of a 360px screen for a figure like `$128,450`.

## What are the common KPI card mistakes?

Most mistakes make a card look precise while it misleads the reader:

- **Coloring by sign.** Red for every drop makes "costs down 8%" look like a problem. Decide per metric whether down is good.
- **Comparing different periods.** "+12% vs. last month" on September 15 compares half a month with a full one. Compare with the same days of the previous period, or label the partial period.
- **Mixing percent and points.** A conversion rate going from 20% to 23% is +3 points, or +15%. Pick one and say which.
- **Showing 0 when there is no data.** `null` should print a dash ("–"), not `$0`, which reads as a real and very bad month.
- **Counting up on every refresh.** Animate once; afterwards move from the old number to the new one, or people stop trusting the figures.

For the chart side of a dashboard, see [React charts with Tailwind](https://wingo-ui.com/blog/react-charts-tailwind) and, for sparklines in table cells on one shared scale, [React sparkline](https://wingo-ui.com/blog/react-sparkline), and for a whole admin app with tables and settings around these cards, the [Next.js admin dashboard tutorial](https://wingo-ui.com/blog/nextjs-admin-dashboard-tutorial). If you are still choosing a library for the whole dashboard, read [the best React component library for dashboards](https://wingo-ui.com/blog/best-react-component-library-for-dashboard). More guides like this one are in [component guides](https://wingo-ui.com/blog/category/components), and the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide) is the pillar for that series.

## What should I do next?

Open the [Stat Card docs](https://wingo-ui.com/components/stat-card) and try the props in the playground; the docs, props and live demos are free to use without an account. Stat Card, Trend Badge, Sparkline and the KPI Dashboard are part of Pro ($8 a month, $80 a year or $150 once, see [pricing](https://wingo-ui.com/pricing)), and a free account gets you the source of every free item if you want to start with the basics.

## Components in this post

- [Stat Card](https://wingo-ui.com/components/stat-card) (Pro): A React stat card for dashboards: a label, a big number that counts up, a delta pill that knows good from bad, a sparkline and a goal bar. Install: `npx wingo-ui@latest add stat-card`
- [KPI Dashboard](https://wingo-ui.com/components/kpi-dashboard) (Pro): A React KPI dashboard: metric tiles with deltas, revenue against the last period, sources, funnel, top sellers, invoices and activity. Install: `npx wingo-ui@latest add kpi-dashboard`
- [Trend badge](https://wingo-ui.com/components/trend-badge) (Pro): A React trend badge, the delta chip for KPIs, table cells and leaderboards: an arrow, a signed change and a color that says if it is good. Install: `npx wingo-ui@latest add trend-badge`
- [Sparkline](https://wingo-ui.com/components/sparkline) (Pro): A React sparkline: a word-sized chart for table cells, list rows, KPI tiles and running text, as a line, an area, bars or win / loss ticks. Install: `npx wingo-ui@latest add sparkline`

## FAQ

### Does shadcn/ui have a KPI card component?

No. As of November 2026 shadcn/ui has no KPI or stat card component. Its dashboard-01 block includes a SectionCards file that composes Card and Badge with hardcoded numbers, and you edit that file for your own metrics.

### How do I show a decrease as good in a React KPI card?

Separate the arrow from the color. The arrow follows the sign of the change, and the color follows the meaning: for costs, churn and response time a drop is positive. In Wingo UI that is the invertTrend prop on StatCard, TrendBadge and Sparkline.

### How many KPI cards should a dashboard show?

Four to six in the top row is a common choice, because more tiles get too narrow to read on a laptop and push the charts below the fold. The KPI Dashboard puts up to six tiles in one row from 1024px of its own width and wraps more than that into several rows.

### How should KPI cards behave on mobile?

Two cards per row is the usual phone layout: one per row wastes height, three gets too tight for a currency value at 360px. Make the last tile span the full row when the count is odd, shrink the number to fit, and keep the delta and label readable.

### Should KPI numbers animate?

A short count-up on first view helps the eye land on the numbers, but the server HTML and screen readers should get the final value, and reduced motion should skip it. Later updates should count from the number on screen, not from zero.

---

Source: https://wingo-ui.com/blog/shadcn-kpi-cards
