Comparison Matrix

Two to five options side by side across shared criteria, with the differences and the best value per row marked, and optionally one to pick.

Pattern

≈ 2,085 tokens per use (tool 1,225 · guidance 346 · typical payload 514)How token costs are estimated

Example: Comparison Matrix

Compare the three CRM plans for a 10-person team.

CRM plans for 10 users

Recommended: Growth. Automation, 25,000 contacts and 8-hour support for under half Scale's cost.

Choose one: CRM plans for 10 users

Pricing

  • Annual cost for 10 users, Billed yearly
    • Starter$1,440, best
    • Growth$3,480
    • Scale$7,080
  • Contacts included
    • Starter2,000
    • Growth25,000
    • Scale100,000, best

Features

  • Sales pipelines
    • Starter1
    • Growth10
    • Scale50, best
  • Workflow automation
    • StarterNo
    • GrowthYes
    • ScaleYes
  • Single sign-on (SSO)
    • StarterNo
    • GrowthNo
    • ScaleYes
  • First response, Business hours
    • Starter48 h
    • Growth8 h
    • Scale1 h, best
  • Dedicated success manager
    • StarterNo
    • GrowthNo
    • ScaleYes

When to use

  • The user wants to compare 2–5 options across the same criteria: plans, products, vendors, candidates, approaches.
  • Seeing where the options differ is the point, and the user may pick one.

When not to use

  • There's one option. Use an Entity Card or a Summary Card.
  • There are more than 5 options, or many records with the same fields. Use a Data Table.
  • The options differ on one number. Say it in a sentence, or use a Chart.
  • The agent can't read the real values. Fetch them first; never fill gaps from memory.

How the agent should use it

  • Every value comes from a tool result. Use null for anything unknown. It's shown as "—" and read as "not known", never guessed.
  • Use the same criteria for every option, grouped (Pricing, Features, Support) and ordered by what the user asked about.
  • Set better only where one direction clearly wins: price lower, battery higher. Leave it off for neutral facts like screen size.
  • Add weight only when the user's priorities are known, like "for video editing". The score is computed from the values; the agent doesn't state its own.
  • A recommendation must follow from the values shown, and its reason must cite them.
  • Set selectable only when the choice changes what the agent does next, like switching plans. Act only on a select result.
  • Lifecycle: prices and plan terms change, so set fetchedAt (and staleAfterMs, e.g. a day) on the call when the values come from a live catalog. When a selectable comparison is built on a quote that only holds for a while, set expiresAt too. Use supersede when the agent re-runs the comparison with different options or criteria.

Interaction

ElementBehavior
Best valueIn rows with better, the winning value is bold with a trophy icon and read as "best". Ties mark every tied option. Nothing is marked when fewer than two values are known or all are equal
Same rowsWith highlight: "differences" (the default), rows where every option has the same value fold under a "3 rows are the same" toggle. This is the one place the library collapses content by default, because the differences are the answer. highlight: "all" shows every row
ScoreWhen any row has a weight, a "Score" row (out of 100, said to screen readers) closes the table. See the formula below
ChooseWith selectable, each column header has a radio (labelled "Choose Growth") and the chosen column is shaded. The submit button sends it, or None of these sends dismiss
Narrow screensBelow the sm breakpoint the table becomes a stacked list per criterion: "Annual cost: Starter $1,440 · Growth $3,480 · Scale $7,080", in a focusable, labelled region that scrolls when the rows are long. From sm up the table scrolls sideways inside the card, with the criterion column pinned, in a focusable, labelled region

While streaming, the column headers arrive first and each row appears once its first value arrives. Best marks wait for the whole row, and same rows fold only once the call is complete, so nothing jumps.

Weighted score

Only booleans and rows with better and a weight above 0 count:

score(item) = round(100 × Σ weight × n(item) / Σ weight)

number, currency, rating:  n = (v − min) / (max − min)   when better is "higher"
                           n = (max − v) / (max − min)   when better is "lower"
                           (min and max over the known values in the row; all equal → 1)
boolean:                   n = 1 for Yes, 0 for No
unknown (null):            n = 0, so missing data can't win

The helpers in comparison-matrix-utils.ts (weightedScores, bestItemIds, partitionRows, formatValue) are pure, so the server can reuse them, for example to check that a recommendation matches the top score.

Installation

npx shadcn@latest add https://your-site.com/r/comparison-matrix.json

Usage

app/api/chat/route.ts
import { tool } from "ai"
import { comparisonMatrixTool } from "@/components/genui/comparison-matrix.schema"

const tools = {
  // Selectable: no execute(), the call is completed by the user's choice.
  // Display-only: add an execute that returns immediately.
  [comparisonMatrixTool.name]: tool({
    description: comparisonMatrixTool.description,
    inputSchema: comparisonMatrixTool.inputSchema,
  }),
}
components/message.tsx
<ComparisonMatrix
  {...part.input}
  status={part.state === "input-streaming" ? "streaming" : undefined}
  result={part.state === "output-available" ? part.output : undefined}
  onResult={(result) =>
    addToolResult({ tool: "compare_options", toolCallId: part.toolCallId, output: result })
  }
/>

With selectable, the agent receives one of:

{ action: "select", itemId: "growth" }
{ action: "dismiss" }

With LangGraph

Push it from a graph node with ui.push({ name: "compare_options", props: { toolCallId, ...args } }, { message }). For a selectable comparison, interrupt() until the user's result arrives. Render the thread's UI messages with the Renderer.

Restoring from history

Pass the stored result back as result. The matrix renders read-only, with the chosen column highlighted and its radio checked, and the footer shows "Chose Growth" (or "Dismissed. Nothing was chosen."). It never calls onResult again.

<ComparisonMatrix
  {...part.input}
  result={part.state === "output-available" ? part.output : undefined}
/>

Schema

PropTypeNotes
titlestring?What is being compared
itemsItem[]2–5 options, shown as columns in this order
criteriaCriterion[]1–40 rows, grouped by group in first-appearance order
valuesRecord<itemId, Record<criterionId, string | number | boolean | null>>Data-bound. null = not known
highlight"differences" | "all"Default differences: same rows fold under a toggle
recommendation{ itemId, reason }?A "Recommended" badge on the column and one sentence in the header
selectablebooleanLet the user choose one option. Default false
submitLabelstring?Submit button text. Default "Choose"

Item

FieldTypeNotes
idstringKeys values; returned on select
namestring
subtitlestring?One short line. Read to screen readers, not shown
badgestring?Data-bound. A real state, e.g. "Current plan"
urlstring?Data-bound. http(s) only; opens in a new tab
price{ amount, currency, period? }?Data-bound. "$29 / user / month"

Criterion

FieldTypeNotes
idstringKeys each item's values
labelstring
groupstring?"Pricing", "Features"
typetext | number | boolean | rating | currencyBooleans as check / minus icons read "Yes" / "No"; ratings as "4.5 / 5" with a small bar
better"higher" | "lower"?For number, currency and rating: marks the best value
unitstring?"GB", "h", "%"
currencystring?ISO 4217 for currency rows. Defaults to the items' price currency
outOfnumber?Top of the rating scale. Default 5
weightnumber?Importance for the weighted score
helpstring?One short line, read with the label by screen readers (not shown)

Host props: status (streaming, ready, submitting, selected, dismissed), onResult, result and className.

Source

On this page