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.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/kpi.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/kpi.jsonbunx shadcn@latest add https://panelui.dev/r/web/kpi.jsonThe source is copied into components/panelui/, where you can edit it.
npm install panelui-webpnpm add panelui-webyarn add panelui-webbun add panelui-webImport 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-variantspnpm add tailwind-variantsyarn add tailwind-variantsbun add tailwind-variantsCopy 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.tonecolours 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.inlinegives it a fixed box,filleda 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
| Prop | Type | Default | Description |
|---|---|---|---|
surface | boolean | — | Draws the metric as a card of its own. Leave it off inside a frame or a group. |
Kpi.Icon
| Prop | Type | Default | Description |
|---|---|---|---|
tone | KpiTone | neutral | How the tile is coloured: by good or bad news, or left neutral. |
Kpi.Content
| Prop | Type | Default | Description |
|---|---|---|---|
layout | 'below' | 'inline' | below | inline puts a chart beside the number in a fixed box; below leaves it under everything. |
Kpi.Trend
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | The change. Its sign decides the direction. |
format | (value: number) => string | — | Writes the change. Defaults to a signed percentage with one decimal: "+7.8%". |
goodDirection | KpiGoodDirection | '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. |
caption | string | — | Quiet words after the change: "this month". |
threshold | number | 0 | A change this small or smaller is drawn as no change. |
Kpi.Chart
| Prop | Type | Default | Description |
|---|---|---|---|
data | number[] | — | The values, oldest first. |
color | KpiColor | 'primary' | Which of the theme's roles the line is drawn in. |
filled | boolean | false | Fills under the line with a wash of the same colour. |
height | number | — | How tall the chart is, in pixels. |
inline | boolean | false | A 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. |
strokeWidth | number | 2 | How thick the line is, in pixels. |
Kpi.Progress
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | How far along. |
maxValue | number | 100 | What value is out of. |
label | ReactNode | — | Words at the start of the row over the bar: "of 5,000". |
showValueLabel | boolean | false | Writes the percentage at the end of the row over the bar. |
color | KpiColor | 'primary' | Which of the theme's roles the bar is drawn in. |
Kpi.Group
| Prop | Type | Default | Description |
|---|---|---|---|
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.