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.
≈ 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.
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
nullfor 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
betteronly where one direction clearly wins: price lower, battery higher. Leave it off for neutral facts like screen size. - Add
weightonly 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
selectableonly when the choice changes what the agent does next, like switching plans. Act only on aselectresult. - Lifecycle: prices and plan terms change, so set
fetchedAt(andstaleAfterMs, 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, setexpiresAttoo. Use supersede when the agent re-runs the comparison with different options or criteria.
Interaction
| Element | Behavior |
|---|---|
| Best value | In 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 rows | With 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 |
| Score | When any row has a weight, a "Score" row (out of 100, said to screen readers) closes the table. See the formula below |
| Choose | With 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 screens | Below 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 winThe 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
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,
}),
}<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
| Prop | Type | Notes |
|---|---|---|
title | string? | What is being compared |
items | Item[] | 2–5 options, shown as columns in this order |
criteria | Criterion[] | 1–40 rows, grouped by group in first-appearance order |
values | Record<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 |
selectable | boolean | Let the user choose one option. Default false |
submitLabel | string? | Submit button text. Default "Choose" |
Item
| Field | Type | Notes |
|---|---|---|
id | string | Keys values; returned on select |
name | string | |
subtitle | string? | One short line. Read to screen readers, not shown |
badge | string? | Data-bound. A real state, e.g. "Current plan" |
url | string? | Data-bound. http(s) only; opens in a new tab |
price | { amount, currency, period? }? | Data-bound. "$29 / user / month" |
Criterion
| Field | Type | Notes |
|---|---|---|
id | string | Keys each item's values |
label | string | |
group | string? | "Pricing", "Features" |
type | text | number | boolean | rating | currency | Booleans 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 |
unit | string? | "GB", "h", "%" |
currency | string? | ISO 4217 for currency rows. Defaults to the items' price currency |
outOf | number? | Top of the rating scale. Default 5 |
weight | number? | Importance for the weighted score |
help | string? | One short line, read with the label by screen readers (not shown) |
Host props: status (streaming, ready, submitting, selected, dismissed), onResult, result and className.