busy-office-ui
Menu

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

RFQ-4471 · Hydraulic pumps, 120 units Awaiting award
Supplier quotes for RFQ-4471, compared on four criteria
Criterion Acme Supply Co.Northwind SupplyVertex 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 41 of 42 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

  1. Subject header — what is being decided and its state (badge).
  2. Criteria rows — one per stated criterion, with its unit in the row header so cells stay bare numbers.
  3. 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.
  4. Best marker + delta — computed per row, never hand-typed; data table tone plus visually-hidden text.
  5. Criteria won — a count in the <tfoot>, not a rank.
  6. 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

RequestReturns / on failure
GET /rfq/:id/comparethe 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/awardone award value; returns the awarded state with the deciding criteria snapshot stored alongside — the record of why, frozen at award time
POST … with no selection422 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 RFQ409 — re-render read-only with who awarded, to whom, and when; an award is not re-decidable in place

States

StateWhat ships
Awaiting awardradios live, submit enabled; every criterion shows its winner and every other cell its delta
A candidate missing a valuean em dash and a visually-hidden "not quoted" — never 0, which would read as the best price and win the row
Tie on a criterionboth cells marked best; the count reflects it. A tie is a real outcome, not something to break arbitrarily
One candidatethe pattern is wrong — there is no comparison; show the quote on record detail instead
Awardedread-only, the winning column marked, the deciding snapshot shown; changes go through a new RFQ round, not an edit
Narrow screenthe table scrolls inside its container; the criterion column is the row header, so the reader never loses which row they are on
No JSfully 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

↑↓ move Enter open Esc close