KPI

One number and what it is doing: its change, its shape over time, its progress to a target.

import { Kpi } from '@/components/panelui/kpi';

/** Each figure over the last thirty days, oldest first. */
const SUBSCRIBERS = [152, 154, 153, 157, 161, 160, 164, 169, 168, 172, 176, 175, 179, 182];
const UNSUBSCRIBES = [14, 13, 13, 12, 12, 11, 12, 10, 10, 9, 9, 8, 8, 7];
const OPENS = [41, 42, 44, 43, 45, 44, 46, 45, 47, 46, 46, 48, 47, 47];

export default function KpiOverview() {
  return (
    // A frame: a band that names what is inside, round a panel that holds it.
    <div className="w-full max-w-md rounded-3xl bg-muted p-1.5">
      <div className="flex items-center justify-between gap-3 px-3.5 py-2.5">
        <h3 className="text-sm font-medium">Newsletter</h3>
        <p className="text-sm text-muted-foreground">Last 30 days</p>
      </div>

      <div className="rounded-[1.125rem] border bg-card p-5 text-card-foreground shadow-xs">
        <Kpi.Group orientation="vertical">
          <Kpi>
            <Kpi.Content layout="inline">
              <Kpi.Stat>
                <Kpi.Title>Subscribers</Kpi.Title>
                <Kpi.Value>18,240</Kpi.Value>
                <Kpi.Trend value={6.4} caption="this month" />
              </Kpi.Stat>
              <Kpi.Chart data={SUBSCRIBERS} inline />
            </Kpi.Content>
          </Kpi>
          <Kpi>
            <Kpi.Content layout="inline">
              <Kpi.Stat>
                <Kpi.Title>Unsubscribe rate</Kpi.Title>
                <Kpi.Value>0.8%</Kpi.Value>
                <Kpi.Trend value={-12.5} goodDirection="down" caption="on last month" />
              </Kpi.Stat>
              <Kpi.Chart data={UNSUBSCRIBES} color="success" inline />
            </Kpi.Content>
          </Kpi>
          <Kpi>
            <Kpi.Content layout="inline">
              <Kpi.Stat>
                <Kpi.Title>Open rate</Kpi.Title>
                <Kpi.Value>47.3%</Kpi.Value>
                <Kpi.Trend value={1.9} caption="last 7 issues" />
              </Kpi.Stat>
              <Kpi.Chart data={OPENS} color="info" inline />
            </Kpi.Content>
          </Kpi>
        </Kpi.Group>
      </div>
    </div>
  );
}

Use it for a number someone checks, with whatever says how it is doing: the change since last time, the shape of it over a period, how far it is towards a target. It has the same parts as the app library's KPI, so a metric reads the same on both.

The number comes first. The trend, the chart and the bar are all small on purpose, and none of them can be read to a value.

For a chart someone needs to read values from, use a chart with an axis. For one bare figure with a label, a Kpi.Title and a Kpi.Value are enough.

Installation

npx shadcn@latest add https://panelui.dev/r/web/kpi.json

The source is copied into components/panelui/, where you can edit it.

npm install panelui-web

Import from panelui-web/kpi instead of @/components/panelui/kpi. Tailwind needs one line to read the package's classes, which is on the installation page.

Install the packages it uses:

npm install tailwind-variants

Copy the source into components/panelui/kpi.tsx:

Usage

import { Kpi } from "@/components/panelui/kpi"
<Kpi>
  <Kpi.Title>Subscribers</Kpi.Title>
  <Kpi.Value>18,240</Kpi.Value>
  <Kpi.Trend value={6.4} caption="this month" />
</Kpi>

Composition

<Kpi>
  <Kpi.Header>
    <Kpi.Icon />
    <Kpi.Title />
    <Kpi.Actions />
  </Kpi.Header>
  <Kpi.Content>
    <Kpi.Stat>
      <Kpi.Value />
      <Kpi.Trend />
    </Kpi.Stat>
    <Kpi.Chart />
  </Kpi.Content>
  <Kpi.Progress />
  <Kpi.Footer />
</Kpi>
  • Kpi.Header — The row over the number. A title in it takes the room between the icon and the actions.
  • Kpi.Icon — A small tile for an icon. tone colours it as good or bad news.
  • Kpi.Title — What the number measures.
  • Kpi.Actions — Whatever belongs at the end of the header: a period, a trend badge, a button.
  • Kpi.Content — The row the number is in. layout="inline" is for a chart beside it.
  • Kpi.Stat — Title, value and trend stacked as one block, for a row with a chart at its other end.
  • Kpi.Value — The number.
  • Kpi.Trend — The change, given as a number. It writes the sign and picks the colour.
  • Kpi.Chart — A sparkline of a list of numbers. inline gives it a fixed box, filled a wash under the line.
  • Kpi.Progress — A bar towards a target, with a label and the percentage over it.
  • Kpi.Footer — A quiet line under everything.
  • Kpi.Separator — A rule between two parts of one card.
  • Kpi.Group — Several metrics as one panel, side by side or stacked, with a rule between each and the next.

Examples

A card of its own

surface draws the metric as a card. The trend is a badge in the header, and the chart runs the width of the card under the number with a wash beneath the line.

import { UsersIcon } from 'lucide-react';
import { Kpi } from '@/components/panelui/kpi';

const READERS = [310, 318, 305, 322, 340, 336, 351, 348, 366, 372, 369, 388, 401, 412];

export default function KpiCard() {
  return (
    <Kpi surface className="max-w-xs">
      <Kpi.Header>
        <Kpi.Icon>
          <UsersIcon />
        </Kpi.Icon>
        <Kpi.Title>Weekly readers</Kpi.Title>
        <Kpi.Actions>
          <Kpi.Trend value={8.2} variant="badge" />
        </Kpi.Actions>
      </Kpi.Header>
      <Kpi.Value>4,120</Kpi.Value>
      <Kpi.Chart data={READERS} color="info" filled height={72} />
      <Kpi.Footer>Counted every Monday</Kpi.Footer>
    </Kpi>
  );
}

Towards a target

Kpi.Progress takes a value and what it is out of. Kpi.Icon takes a tone, so the tile can agree with the news beside it.

import { BanknoteIcon, TargetIcon } from 'lucide-react';
import { Kpi } from '@/components/panelui/kpi';

export default function KpiTargets() {
  return (
    // A frame: a band that names what is inside, round a panel that holds it.
    <div className="w-full max-w-md rounded-3xl bg-muted p-1.5">
      <div className="flex items-center justify-between gap-3 px-3.5 py-2.5">
        <h3 className="text-sm font-medium">Targets</h3>
        <p className="text-sm text-muted-foreground">This quarter</p>
      </div>

      <div className="rounded-[1.125rem] border bg-card p-5 text-card-foreground shadow-xs">
        <Kpi.Group orientation="vertical">
          <Kpi>
            <Kpi.Header>
              <Kpi.Icon tone="good">
                <TargetIcon />
              </Kpi.Icon>
              <Kpi.Title>Paid members</Kpi.Title>
              <Kpi.Actions>
                <Kpi.Trend value={5.1} variant="badge" />
              </Kpi.Actions>
            </Kpi.Header>
            <Kpi.Value>1,463</Kpi.Value>
            <Kpi.Progress value={1463} maxValue={2000} label="of 2,000" showValueLabel />
            <Kpi.Footer>38 days left in the quarter</Kpi.Footer>
          </Kpi>
          <Kpi>
            <Kpi.Header>
              <Kpi.Icon>
                <BanknoteIcon />
              </Kpi.Icon>
              <Kpi.Title>Sponsorship</Kpi.Title>
              <Kpi.Actions>
                <Kpi.Trend value={-3.4} variant="badge" />
              </Kpi.Actions>
            </Kpi.Header>
            <Kpi.Value>€9,800</Kpi.Value>
            <Kpi.Progress
              value={9800}
              maxValue={24000}
              label="of €24,000"
              showValueLabel
              color="warning"
            />
          </Kpi>
        </Kpi.Group>
      </div>
    </div>
  );
}

Several as one panel

Kpi.Group puts a rule between metrics and shares the width between them. Without a header each metric is a title, a value and a trend.

import { Kpi } from '@/components/panelui/kpi';

export default function KpiPanel() {
  return (
    <div className="w-full max-w-md rounded-3xl border bg-card p-5 text-card-foreground">
      <Kpi.Group>
        <Kpi>
          <Kpi.Title>Sent</Kpi.Title>
          <Kpi.Value>112</Kpi.Value>
          <Kpi.Trend value={3.7} />
        </Kpi>
        <Kpi>
          <Kpi.Title>Replies</Kpi.Title>
          <Kpi.Value>386</Kpi.Value>
          <Kpi.Trend value={14.2} />
        </Kpi>
        <Kpi>
          <Kpi.Title>Bounces</Kpi.Title>
          <Kpi.Value>0.6%</Kpi.Value>
          <Kpi.Trend value={-0.4} goodDirection="down" />
        </Kpi>
      </Kpi.Group>
    </div>
  );
}

API Reference

Kpi

PropTypeDefaultDescription
surfaceboolean—Draws the metric as a card of its own. Leave it off inside a frame or a group.

Kpi.Icon

PropTypeDefaultDescription
toneKpiToneneutralHow the tile is coloured: by good or bad news, or left neutral.

Kpi.Content

PropTypeDefaultDescription
layout'below' | 'inline'belowinline puts a chart beside the number in a fixed box; below leaves it under everything.

Kpi.Trend

PropTypeDefaultDescription
valuenumber—The change. Its sign decides the direction.
format(value: number) => string—Writes the change. Defaults to a signed percentage with one decimal: "+7.8%".
goodDirectionKpiGoodDirection'up'Which direction is the good news. down for churn, latency, cost; none for no colour.
variant'text' | 'badge''text'text is a line of colour; badge puts a pill and an arrow round it.
captionstring—Quiet words after the change: "this month".
thresholdnumber0A change this small or smaller is drawn as no change.

Kpi.Chart

PropTypeDefaultDescription
datanumber[]—The values, oldest first.
colorKpiColor'primary'Which of the theme's roles the line is drawn in.
filledbooleanfalseFills under the line with a wash of the same colour.
heightnumber—How tall the chart is, in pixels.
inlinebooleanfalseA fixed box for the end of a layout="inline" row. Charts beside numbers of different lengths are then all one width, and line up down a column.
strokeWidthnumber2How thick the line is, in pixels.

Kpi.Progress

PropTypeDefaultDescription
valuenumber—How far along.
maxValuenumber100What value is out of.
labelReactNode—Words at the start of the row over the bar: "of 5,000".
showValueLabelbooleanfalseWrites the percentage at the end of the row over the bar.
colorKpiColor'primary'Which of the theme's roles the bar is drawn in.

Kpi.Group

PropTypeDefaultDescription
orientation'horizontal' | 'vertical'—Side by side with upright rules, or stacked with level ones.

Every part also takes the props of the element it renders, and a className.

Notes

Trend colours

Kpi.Trend is drawn in the success colour when the number moved the good way, in the destructive colour when it moved the bad way, and in the muted colour when it did not move. Up is good unless you say otherwise: pass goodDirection="down" for a metric where less is better, or "none" for one where neither is.

threshold is the size of change that still counts as none. With threshold={0.5}, a change of 0.3 is drawn flat.

Writing the change

The change is written as a signed percentage with one decimal. format is given the value and returns the text, for a change in another unit. caption adds quiet words after it and is read out with it.

The chart

data is a list of numbers, oldest first. The line is scaled between the lowest and highest of them, so it shows the shape of the change and says nothing about its size. That is why it has no axis.

inline fixes the chart at 128 by 56 pixels. In a column of cards with titles of different lengths, the charts are then all one width and line up down the right-hand edge.

Colour

color on Kpi.Chart and Kpi.Progress names one of the theme's roles and defaults to primary.

In a group

Kpi.Group pads its children on the sides that face a rule. Put Kpi elements straight inside it. In a horizontal group the metrics share the width equally, so keep titles to a word or two, or stack them with orientation="vertical".

Server Components

KPI holds no state, so it is not a client module. It can be rendered from a Server Component as written, and it adds no JavaScript to the page.

On this page