Confusion Matrix
A predicted × actual grid that reads as raw counts or row/column normalized shares, with per-class recall and precision margins, a hatched off-diagonal, and undefined rates kept apart from zero.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-confusion-matrix.jsonPrompt
Build a React + TypeScript + Tailwind "ChartConfusionMatrix" card with zod and
lucide-react. It is a real HTML <table>, not SVG and not a recharts series: the
thing on screen IS a cross-tab, the numbers have to survive a copy-paste into a
spreadsheet, and every cell has to be an addressable focus target. All the maths
lives in exported pure functions beside the schema, so a test can print the same
figures the picture is painted with.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready";
classes: { id: string; label: string }[]; // ONE list, driving BOTH axes
tallies: { actual: string; predicted: string; count: number }[];
sample?: { one: string; many: string } } // "frame"/"frames", default sample
- Row i and column i are the same class, in the order given, and that identity is
the only reason the diagonal means "correct". Ordering is the data layer's job:
put classes that get confused with each other next to each other and the
mistakes gather into a band beside the diagonal instead of scattering.
- AN ABSENT PAIR IS A REAL ZERO, not a missing measurement. A confusion matrix
partitions every scored sample, so sparse input is the normal case and nothing
needs padding client-side. This is the opposite of a generic heatmap, which
must keep null and 0 apart; here there is no "no data" fill at all.
- DUPLICATE PAIRS ARE SUMMED, never last-wins: a tally is additive, and a
per-shard, per-batch or per-day response legitimately reports one pair twice.
- count is z.number().nonnegative(), so NaN, Infinity and negatives are contract
violations. superRefine also rejects duplicate class ids, tallies naming a
class outside the label set, and a "ready" payload with nothing scored. Guard
every access inside superRefine - sibling refinements all run, so a ragged
payload has to produce an issue rather than a TypeError out of safeParse.
- Props = z.infer of the schema plus title, description, normalize
("none" | "row" | "column", default "none"), margins (default true),
showValues (boolean | "auto"; auto prints numbers up to 12 classes), cellSize
(px, clamped 20-120, default 56 with numbers and 26 without), decimals (0-3,
default 1), locale (default "en-US", because Intl.*(undefined) desyncs SSR
from the visitor's locale), skeletonClasses (2-12), onRetry, onCellSelect,
emptyState, className and the div's native props. forwardRef to the card.
- Export the maths: buildConfusionModel(), cellDenominator(), cellShare(),
cellIntensity(), inkMix().
Behavior
- buildConfusionModel is the component. Long-form tallies in, and out comes the
dense counts matrix plus every figure printed anywhere on the card: support
(row total), called (column total), hits (the diagonal cell), recall =
hits/support, precision = hits/called, F1 = the harmonic mean, accuracy =
correct/total, macro F1 = the unweighted mean of the defined F1s, the largest
off-diagonal cell, the largest cell anywhere, and TWO drop counters - unusable
count and unknown class id - kept apart because they are different bugs.
- 0/0 IS UNDEFINED, NOT 0%. A class with no samples in the slice has no recall;
a class the model never predicts has no precision. Both print an em dash and
say why in the tooltip. Printing 0% there would accuse the model of missing
something that was never in the set, and it is the most common bug in
hand-rolled confusion matrices - which is exactly why the maths is a pure
function you can assert on. The mirror of that rule: a class whose precision
and recall are both a MEASURED 0 scores F1 = 0, not NaN, or a class the model
gets entirely wrong would drop out of the macro average and flatter it.
- NOTHING IS DROPPED IN SILENCE. A NaN, an infinity, a negative or a tally aimed
at a class outside the label set is counted in its bucket, and the card prints
a sentence naming both numbers. The contract rejects all of them, but props
are not always parsed, so the renderer degrades visibly instead of quietly
lowering every rate on the card.
- NORMALIZATION decides what the colour - and, off "none", the printed number -
is a share of, and it is the whole reason this is not just a heatmap:
none t = count / the biggest cell on the grid. The honest picture of the
tally you were handed. With uneven class sizes one diagonal tile owns
the ceiling and the minority classes go blank; that is not a
rendering fault, it is the finding, and the legend names the ceiling.
row t = count / that row's support. A row sums to 100% and THE DIAGONAL
READS AS RECALL, which is also what the right margin prints.
column t = count / everything called that class. A column sums to 100% and
THE DIAGONAL READS AS PRECISION, matching the bottom margin.
Both normalized modes are fixed on 0-100%, so two cards stay comparable.
- COLOUR IS NEVER THE ONLY CHANNEL. Every cell prints its number while the
numbers fit; the diagonal is solid with a semibold digit and every off-diagonal
cell with samples in it carries a 135-degree hatch, so "correct" and "confused"
survive greyscale, colour blindness and a photocopier. An exact zero gets no
ink at all - an empty grid reads empty - while the smallest non-zero cell still
gets a visible tint. Margins print a percentage AND a small bar.
- MARGINS: a recall column on the right, a precision row along the bottom, and
overall accuracy in the corner (its tooltip also carries macro F1). They live
in the same coordinate system as the grid, so the arrow keys walk into them.
- KEYBOARD, and a gesture is never the only path. The whole grid is ONE tab
stop with a roving tabindex: arrows move and CLAMP (never wrap - arrowing off
the right edge should stop, not teleport to the next class), Home/End jump
along the row, Ctrl/Cmd+Home and Ctrl/Cmd+End to the corners, Enter or Space
pins the focused cell, Escape releases it. Space is always preventDefault-ed
so the page cannot scroll out from under the reader; Escape is only swallowed
when there is a pin to release, so it still closes the dialog the card sits in.
Moving focus is a synchronous .focus() on the target cell - the state update
rides in on the focus event instead of waiting for a render.
- POINTER: one delegated listener each for mouseover, focus, blur, click and
keydown on the <table>, not one per cell; a 15-class grid with margins is 256
cells and per-cell closures would be rebuilt on every hover. Hover and focus
feed a readout line under the grid, which is what carries the values when
showValues is off. Clicking a cell pins it, clicking it again releases it, and
the pin survives the pointer leaving.
- ARIA: no role="img" - that is children-presentational and would silence every
focusable cell. Real table semantics instead: th scope="col" / scope="row",
the table named by the heading with aria-labelledby and described by the
summary line with aria-describedby, and one sentence per cell on aria-label
("Grey wolf called Feral dog: 38 frames, 19.2% of everything that is Grey
wolf, 7.2% of everything called Feral dog."). The visible readout is
aria-hidden, because a focused cell already announces itself and a live region
would say it twice. The ONE thing focus cannot announce is the pin, because
focus does not move when it happens - so a polite live region carries exactly
that: "Pinned. ..." and "Selection cleared."
- FOCUS SAFETY: if the label set changes, the cell holding focus can be
unmounted, and browsers move focus to <body> without firing blur. Track
"the keyboard was in here" on a ref (set on focus, cleared on blur only when
relatedTarget is outside the table), and after a render where activeElement
has fallen to <body>, put focus back on the clamped tab stop.
- Four first-class branches of one card: loading (a plain-div skeleton with its
own diagonal, aria-hidden, plus one sr-only role=status line - never an
sr-only <table>: CSS width is only a lower bound for a table box, so a 1x1
sr-only table still lays out full width and pushes a scrollbar onto the page),
empty, error (a Try again button only when onRetry was passed), ready. A ready
payload with no classes or nothing scored renders the empty branch, and the
drop note is printed under the empty branch too, since it usually explains why.
- CLEANUP: there are no timers, no rAF, no observers and no simulation here, so
there is nothing to cancel. Layout is CSS; nothing measures the DOM.
Rendering & styling
- Semantic tokens only. The fill is color-mix(in oklab, var(--chart-1) X%,
var(--card)) with X ramping 7% to 76%. Mixing into the SURFACE rather than
fading to transparent makes the ramp travel away from the card in both themes
- darker on light, lighter on dark - so --foreground stays readable on every
step (measured ~7.7:1 light, ~6.4:1 dark at the ceiling). The hatch alternates
one stripe toward --foreground and one toward --card at the same strength, so
it signals "mistake" without adding apparent depth.
- Cell gaps come from shadow-[inset_0_0_0_1px_var(--card)], not border-spacing,
and the pinned cell overrides that same inset shadow with a 3px --foreground
ring, which keeps it distinct from the hover and focus OUTLINES.
- table-fixed plus a <colgroup> of declared widths - a 9rem label column, one
cellSize column per class, a 5rem margin column - and deliberately NO w-full on
the table, so its width is a function of the class count and never of the
longest label, and a cell stays the square it was asked for instead of being
stretched to fill a wide card. A stretched grid tilts the diagonal off 45
degrees and it stops reading as a diagonal at all. Column headers
truncate to one line with the full string on title; row labels line-clamp to
two lines with wrap-anywhere, and carry their support as a second line.
border-separate border-spacing-0, because border-collapse drops borders.
- All the prose - the accessible name, the legend, the drop note, the sort of
wording a reader needs - lives OUTSIDE the table or on title/aria-label
ATTRIBUTES, never as text nodes inside cells: a selection copied into a
spreadsheet has to paste as bare numbers, and a text node in a cell would
paste too.
- Axis and legend text is text-muted-foreground text-xs; the grid is
tabular-nums throughout so digits line up column to column.
- Motion: the only animation is the loading skeleton's pulse, carrying
motion-reduce:animate-none. The card is complete and readable with animation
off, and nothing is time-derived, random or measured at render, so server and
client always draw the same grid.
Customization levers
- normalize is the knob that changes the reading, not the looks: ship "row" as
the default for imbalanced problems, "none" when the audience needs the raw
tally, and wire an external segmented control to it - the component is
controlled by the prop and holds no mode state of its own.
- Density: cellSize + showValues + margins. 56px with numbers is a table you can
quote from; 22-26px with showValues={false} and margins={false} is a dashboard
tile that reads as a shape. Past 12 classes, "auto" already makes that switch
for you.
- Palette: swap --chart-1 for any single token, or raise INK_CEILING for a
heavier grid - re-check contrast if you go past ~80%, since the printed digits
sit on that fill. If your theme has a second hue, give the OFF-DIAGONAL that
hue at the SAME mix percentage so magnitude stays honest, and keep the hatch
as the colour-independent channel.
- Emphasis curve: apply a gamma to t in cellIntensity (t ** 0.7 opens up the low
end) when a long tail of small confusions matters more than the big cells.
- Margins: swap recall for F1 per class, or add a support column, by extending
the model - buildConfusionModel already computes precision, recall and F1 for
every class. Drop them entirely with margins={false} for an embedded tile.
- Order: hand classes[] pre-sorted (by support, by a hierarchical clustering of
the confusion structure, or by a domain taxonomy) to make blocks pop; the
component draws exactly the order it is given.
- Interaction: onCellSelect carries the two class stats, the count and both
shares - wire it to a filtered sample browser so a click on the worst cell
opens the 38 frames behind it. That drill-down is what turns the picture into
an error-analysis tool.Concepts
- Diagonal as the hit rate — row i and column i are the same class, so the diagonal is the set of predictions that were right. Everything a confusion matrix is for lives in how much ink sits off that line and which side of it the ink is on: above the diagonal and below it are two different failure stories about the same pair of classes.
- Row vs column normalization — dividing a row by its support turns the diagonal into recall (of everything that was this class, how much did we catch); dividing a column by everything called that class turns it into precision (of everything we called this class, how much really was). They are different questions, they disagree constantly, and the raw grid answers neither on its own.
- Class imbalance — with 9,400 legitimate transactions against 210 fraudulent, the raw-count grid is one huge tile and three specks, and the entire minority class is invisible. That is the honest picture of the tally, not a rendering fault; normalizing by row is what makes the small class legible again, and the legend always says which reading is in force.
- Undefined is not zero — a class with no samples has recall
0/0, and a class the model never predicts has precision0/0. Both print an em dash, never 0%, because 0% is a measured claim that the model missed something — and nothing was ever there to miss. - Absent means zero — a confusion matrix partitions every scored sample, so a pair nobody reported is a pair nothing landed in. Sparse tallies need no padding, duplicate pairs are summed rather than overwritten, and unlike a general-purpose heatmap there is no "missing" fill to distinguish, because missing cannot happen.
- Grid as one tab stop — a 15-class matrix with margins is 256 cells and 256 tab stops would be unusable, so a roving tabindex gives it one. Arrows walk cells and margins alike, movement clamps instead of wrapping, and the pin — the one state change focus cannot announce, since focus does not move when it happens — goes out through a polite live region.
ROC Curve
A four-state ROC card — one curve per model with its AUC, the chance diagonal, and a draggable, keyboard-reachable threshold handle that reads TPR, FPR and the counts behind them at every cutoff.
Calibration Curve
A four-state reliability diagram — binned predicted probability against observed frequency, read off the diagonal, with Wilson intervals, a shared-axis count strip and every bin reachable from the keyboard.