Pattern: comparison matrix
Who uses it: whoever must choose one of several candidates on stated criteria — a buyer awarding an RFQ, an analyst picking a vendor, a manager comparing budget scenarios. How often: once per decision, but the decision is defensible for years afterwards. What "done" looks like: one candidate selected, and the criteria that decided it visible beside the choice — the record of why is as much the output as the winner. Candidates are columns, criteria are rows, and the best value per row is computed and marked rather than left for the reader to spot. Not for comparing versions of one record — old-vs-new on a single entity is an audit concern, shown as the change table inside record detail's history; and not for picking one row out of a long list — that is value help. Reach for this only when a handful of candidates must be weighed on the same named criteria.
The screen
| Criterion | Acme Supply Co. | Northwind Supply | Vertex Industrial |
|---|---|---|---|
| Unit price $ | 770.00+27.50 | 742.50 — best on this criterion | 803.00+60.50 |
| Lead time days | 14+5 | 21+12 | 9 — best on this criterion |
| On-time delivery % | 96 — best on this criterion | 91−5 | 88−8 |
| Quality score /100 | 88−4 | 84−8 | 92 — best on this criterion |
| Criteria won | 1 of 4 | 1 of 4 | 2 of 4 |
| Award |
Two channels on every "best", and neither is colour.
The winning value is set in bold — the mark lands on
the number itself and survives greyscale and forced-colours — plus a
visually-hidden "best on this criterion" for a screen reader.
Deliberately NOT data-tone: every row here has exactly one
winner, so a toned cell would be carrying ranking rather than
exception, and a stripe on every row reads as texture instead
of "look here" (see the
data table's own guidance
on when that bar is the right mark). It is also the mark the references
use — GSMArena bolds the winning spec. Every other
cell shows its delta from the best in that criterion's own
units (+60.50, +12 days), because
"nine days slower" is the thing a buyer argues about; a normalised
0–100 score hides the trade-off it is supposed to expose.
No candidate wins every row, and the table does not pretend otherwise. Vertex is cheapest on nothing but leads on quality and lead time; Northwind wins on price alone. "Criteria won" is a count, deliberately not a ranking — the decision stays the buyer's, and the screen's job is to make the trade-off legible, not to make the choice.
Why one winner, not a split award
Enterprise sourcing tools (Ariba's award scenarios, Coupa's optimisation) can allocate a quantity across several suppliers — 60/40 — and that is real procurement practice. This pattern deliberately ships select-one first: a radio column, one submit, one winner. Splitting an award changes the screen's whole contract (per-line allocation, quantity validation against the total, a residual that must reach zero) and would double the pattern's weight for a case most consumers do not have. If your process needs it, the honest composition today is this table for the decision plus an editable grid for the allocation — and that is the signal to re-open this, with a real screen to design against.
Markup
<!-- Candidates as COLUMNS, criteria as ROWS. The best value per
row is DERIVED and marked in two channels, NEITHER of them colour: bold
weight on the number itself, plus a visually-hidden "best" phrase. Not
data-tone — every row has a winner here, so a tinted cell would carry
ranking rather than exception. One radio column commits to ONE winner. -->
<form method="post" action="/rfq/4471/award">
<table class="bo-data-table">
<thead>
<tr>
<th scope="col">Criterion</th>
<th scope="col" class="bo-data-table__col--numeric">Acme Supply Co.</th>
…one column per candidate…
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Unit price <span class="bo-u-text-muted">$</span></th>
<td class="bo-data-table__col--numeric">
<strong>742.50</strong><span class="bo-visually-hidden"> — best on this criterion</span>
</td>
<td class="bo-data-table__col--numeric">
803.00 <span class="bo-u-text-muted">+60.50</span>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Criteria won</th>
<td class="bo-data-table__col--numeric">2 of 4</td>
</tr>
<tr>
<th scope="row">Award</th>
<td><label><input type="radio" name="award" value="acme" class="bo-radio" /> Award</label></td>
</tr>
</tfoot>
</table>
<button class="bo-btn" type="submit">Award to selected supplier</button>
</form> Anatomy
- Subject header — what is being decided and its state (badge).
- Criteria rows — one per stated criterion, with its unit in the row header so cells stay bare numbers.
- Candidate columns — one per candidate; keep it to a handful. Past ~6 the table stops being scannable and the real fix is a shortlist step, not a wider table.
- Best marker + delta — computed per row, never hand-typed; data table tone plus visually-hidden text.
- Criteria won — a count in the
<tfoot>, not a rank. - Award row — one radio per candidate (form controls) and a single submit; the radio group makes "exactly one" a property of the markup rather than a rule the server has to police alone.
Data contract
| Request | Returns / on failure |
|---|---|
| GET /rfq/:id/compare | the full matrix, with best-per-criterion computed SERVER-side — the browser must never be the only place the winner is decided, or a client with JS off sees an unmarked table |
| POST /rfq/:id/award | one award value; returns the awarded state with the deciding criteria snapshot stored alongside — the record of why, frozen at award time |
| POST … with no selection | 422 naming the missing choice; the radio group makes this hard to reach from the UI but never impossible from a replayed request |
| POST … on an awarded RFQ | 409 — re-render read-only with who awarded, to whom, and when; an award is not re-decidable in place |
States
| State | What ships |
|---|---|
| Awaiting award | radios live, submit enabled; every criterion shows its winner and every other cell its delta |
| A candidate missing a value | an em dash and a visually-hidden "not quoted" — never 0, which would read as the best price and win the row |
| Tie on a criterion | both cells marked best; the count reflects it. A tie is a real outcome, not something to break arbitrarily |
| One candidate | the pattern is wrong — there is no comparison; show the quote on record detail instead |
| Awarded | read-only, the winning column marked, the deciding snapshot shown; changes go through a new RFQ round, not an edit |
| Narrow screen | the table scrolls inside its container; the criterion column is the row header, so the reader never loses which row they are on |
| No JS | fully functional — a plain form with a radio group and a submit; best-marking and deltas are server-rendered, so nothing on this screen depends on the browser |
Components used
complexity 2 of 4