KPI tile
Headline figure with period delta, a moved-since-last-pull marker, and no fabricated zeroes
npx shadcn@latest add https://ethankd.com/r/kpi-tile.jsonSample metric
12,480
+21%vs previous 28 daysSample data
Sample rate
23.8%
moved since last pull-12%
Sample alert
37
+208%Higher is worse
Not measured
—
No denominator this period
/**
* A headline figure with the comparisons that make it honest.
*
* The tile itself is easy. What this encodes is four refusals:
*
* **`null` is not zero.** A rate with no denominator has no value. "0.0%
* click-through" reads as a measured failure when the truth is that no
* impressions were recorded at all, and a headline tile is the worst place on a
* page for that confusion. `null` renders an em dash, never a placeholder digit.
*
* **A delta needs both sides.** `previous` is optional and must be omitted when
* no honest comparison exists — never passed as 0 to manufacture one. A zero
* prior period makes every increase infinite growth, and it is not evidence of
* no activity either: it is usually evidence that the measurement did not exist
* yet. The tile states that limit instead of reporting the artefact as a fact.
*
* **Colour is never the only cue.** The delta badge carries an arrow, a sign and
* a colour, so the direction survives a reader who sees none of the three the
* same way.
*
* **A figure that changes under the reader is marked.** `lastPull` drives a
* "moved since last pull" chip. A refresh that silently swaps numbers is a
* change-blindness problem, not a stylistic one — the reader cannot be assumed
* to have memorised the old value, so the arrival of new data is made an
* explicit event. Float noise does not count as movement.
*
* The `hero` surface is a separate valence path on purpose. Painted on the
* filled tile the ordinary badge tokens measured 1.15:1 — dark red on a dark red
* tint over a dark fill. Colour correctness is not a property of a token on its
* own, it is a property of a token and the thing behind it, so the hero badge
* takes its tint from white at low alpha and keeps the hue entirely in the text.
*/
type KpiProps = {
label: string;
/** `null` means the figure could not be computed, which is not zero. */
value: number | null;
/** Render as a percentage. `value` is still a 0..1 fraction. */
percent?: boolean;
/** ISO 4217 code. Renders `value` as money when set. */
currency?: string;
/** BCP 47 tag for digit grouping. Defaults to the reader's own locale. */
locale?: string;
/** Prior-period value. Omit when no honest comparison exists — never pass 0. */
previous?: number | null;
previousLabel?: string;
/** Fills the tile with the accent. Exactly one per row. */
hero?: boolean;
/** Marks the figure as the problem rather than the result. */
alert?: boolean;
/** Value at the previous pull, when one was retained. Drives the moved marker. */
lastPull?: number;
sub?: string;
icon?: React.ReactNode;
};
function Delta({
value,
previous,
label,
hero,
noHistoryNote,
}: {
value: number;
previous: number;
label?: string;
hero?: boolean;
noHistoryNote: string;
}) {
if (previous <= 0) {
return <span className="text-iq-micro opacity-90">{noHistoryNote}</span>;
}
const change = (value - previous) / previous;
const up = value >= previous;
return (
<span className="inline-flex items-center gap-1 text-iq-micro">
<span
className={[
"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 font-medium",
hero
? up
? "bg-white/15 text-iq-pos"
: "bg-white/15 text-iq-neg"
: up
? "bg-iq-pos/12 text-iq-pos"
: "bg-iq-neg/12 text-iq-neg",
].join(" ")}
>
{/* Arrow plus sign plus colour: three cues, so colour is never alone. */}
<span aria-hidden="true">{up ? "↑" : "↓"}</span>
{`${change > 0 ? "+" : ""}${(change * 100).toFixed(0)}%`}
</span>
{label ? <span className={hero ? "opacity-80" : "text-iq-ink-muted"}>{label}</span> : null}
</span>
);
}
export function KpiTile({
label,
value,
percent,
currency,
locale,
previous,
previousLabel,
hero,
alert,
lastPull,
sub,
icon,
noHistoryNote = "No comparison: no earlier history for this measure",
}: KpiProps & { noHistoryNote?: string }) {
// Only a real movement counts. Floating-point noise on a ratio is not news,
// and a figure that could not be computed has not "moved" either.
const moved =
value !== null &&
lastPull !== undefined &&
Math.abs(value - lastPull) > Math.max(1e-9, Math.abs(value) * 1e-6);
const formatted =
value === null
? null
: new Intl.NumberFormat(
locale,
currency
? { style: "currency", currency, maximumFractionDigits: 0 }
: percent
? { style: "percent", maximumFractionDigits: 1 }
: { maximumFractionDigits: 0 },
).format(value);
return (
<div
data-kpi
className={`flex min-w-0 flex-col justify-between gap-5 rounded-iq-panel border p-6 ${
hero ? "border-transparent bg-iq-ink text-iq-canvas" : "border-iq-hairline bg-iq-panel"
}`}
>
<div className="flex items-start justify-between gap-3">
<span className={hero ? "text-[13px] opacity-80" : "text-[13px] text-iq-ink-muted"}>
{label}
</span>
{icon ? (
<span
aria-hidden="true"
className={[
"grid size-8 shrink-0 place-items-center rounded-full text-[13px]",
hero ? "bg-black/10" : alert ? "bg-iq-neg/12 text-iq-neg" : "bg-iq-panel-raised",
].join(" ")}
>
{icon}
</span>
) : null}
</div>
<div>
{/* The alert figure is the only saturated element in a neutral band.
Colour and size are the two channels the eye finds without
searching, so spending them anywhere else costs the tile the job it
exists for. The label, the sub-line and the icon carry the same
message, so colour is never doing the work alone. */}
<div
className={`text-[28px] leading-none font-semibold tabular-nums ${
alert && !hero ? "text-iq-neg" : ""
}`}
>
{formatted ?? (
<span className={hero ? "opacity-70" : "text-iq-ink-muted"} title="Not measured">
—
</span>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1">
{moved ? (
<span
data-moved
className={`inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-iq-micro font-medium ${
hero ? "bg-black/15" : "bg-iq-panel-raised text-iq-ink"
}`}
>
{/* Direction is stated in words as well as an arrow, so the
marker does not depend on recognising a glyph. */}
<span aria-hidden="true">{value! > lastPull! ? "↑" : "↓"}</span>
moved since last pull
</span>
) : null}
{/* No comparison against a figure that has no value. */}
{previous !== undefined && previous !== null && value !== null ? (
<Delta
value={value}
previous={previous}
label={previousLabel}
hero={hero}
noHistoryNote={noHistoryNote}
/>
) : null}
{sub ? (
<span
className={
hero
? "text-iq-micro opacity-80"
: `text-iq-micro ${alert ? "text-iq-neg" : "text-iq-ink-muted"}`
}
>
{sub}
</span>
) : null}
</div>
</div>
</div>
);
}
Playground
Sample metric
12,480
+21%vs previous period
<KpiTile
label="Sample metric"
value={12480}
previous={10310}
previousLabel="vs previous period"
/>