Seen as a design The generated document itself — its toggles and disclosures work here
Seen as a design The generated document itself — its toggles and disclosures work here
Copy and paste, or save a file and hand it over. For a machine with no network, one “Everything” file is enough.
Everything in this panel, including the contents behind each URL, in one Markdown file — Large, so it goes as a file (copy is for 32 KB and under)
Build an HTML document with "Comparison Table (Product / Vendor Selection)". This layout runs, so there is no composition to rebuild — what it needs is content.
## What this layout does
Candidates compared criterion by criterion. The column with the most ticks is found from the data, and the heading, each mark and the tally sit on one recommendation spine
## What you have to supply
- The things being compared
- Criteria that apply to all of them
- A value or a yes/no for each criterion
## The shape of the input
- `title` — string — スライドタイトル。結論を書くメッセージラインを推奨
- `subtitle` — string(任意)— 補足・出典など
- `columns` — string[] — 比較対象(列見出し)
- `rows` — { criterion, values: (boolean|string)[], note? } の配列。values は列数分。boolean は ○/× マーク、string は自由記述。true の数が最多の列を自動推奨し、その列の boolean は加点/非加点、string は参考として表示。note は表の下の補足欄で展開
schema.ts holds the machine-checkable validation. Ask rather than guess when an input is missing.
## When not to use it
- Showing position or priority on two axes
- Diagnosing every row-column intersection by shade
## How to build it
Put the input in a JSON file and run:
```bash
node scripts/render.mjs comparison-table <input.json> --theme ink -o out.html
```
The output is one self-contained file and fetches nothing at render time.
## The source
- https://design-vault.pages.dev/designs/comparison-table/source/template.tsx
- https://design-vault.pages.dev/designs/comparison-table/source/schema.ts
- https://design-vault.pages.dev/designs/comparison-table/source/sample-data.json
- https://design-vault.pages.dev/designs/comparison-table/sample.html Template, input schema, sample data and metadata. This document was generated from those four. The generated document
/designs/comparison-table/source/template.tsx
import { useId } from "react";
import type { ComparisonTableProps } from "./schema";
/** 列ごとに述語を満たす値の数を集計する(scoreColumns/booleanRowCounts共通の集計形) */
const tallyColumns = (
rows: { values: (boolean | string)[] }[],
columnCount: number,
predicate: (v: boolean | string) => boolean,
): number[] => {
const counts = Array(columnCount).fill(0);
for (const row of rows) {
row.values.forEach((v, i) => {
if (predicate(v)) counts[i] += 1;
});
}
return counts;
};
/** 列ごとに true の数を集計する(string 値は集計対象外) */
export const scoreColumns = (
rows: { values: (boolean | string)[] }[],
columnCount: number,
): number[] => tallyColumns(rows, columnCount, (v) => v === true);
/**
* 最多スコアの列 index を返す。全列0点なら強調なし(null)、同点は先頭列を採用
* (DESIGN.md: 大胆さは1画面1箇所)。
*/
export const bestColumnIndex = (scores: number[]): number | null => {
const max = Math.max(...scores);
if (max <= 0) return null;
return scores.indexOf(max);
};
/** highlight(ベスト列)専用グラデーション(DESIGN.md: indigo→violet 限定・1件のみ) */
const HIGHLIGHT_GRADIENT = "bg-linear-to-br from-tertiary to-accent-violet";
/**
* 列ごとにboolean値を持つ行数(集計フッターの分母)。string値の行
* (初期費用/月額料金など)は分母から除外する — rows.length をそのまま
* 使うと文字列行が混ざったデータで分母が過大になる。
*/
export const booleanRowCounts = (
rows: { values: (boolean | string)[] }[],
columnCount: number,
): number[] => tallyColumns(rows, columnCount, (v) => typeof v === "boolean");
/** 分母0件は「–」にフォールバックする(0/0という無意味な表記を避ける) */
export const formatTally = (score: number, total: number): string =>
total === 0 ? "–" : `${score}/${total}`;
/** desktop/mobileと評価根拠の対応を共有する安定した座標code */
export const columnCode = (columnIndex: number): string =>
`C${String(columnIndex + 1).padStart(2, "0")}`;
export const criterionCode = (rowIndex: number): string =>
`E${String(rowIndex + 1).padStart(2, "0")}`;
export type EvidenceKind = "supporting" | "counter" | "context";
/** 推奨列の値がboolean集計へどう関与するかを表示用に分類する */
export const evidenceKind = (value: boolean | string): EvidenceKind =>
value === true ? "supporting" : value === false ? "counter" : "context";
/** mobileは推奨候補を先に読み、残りは元の入力順を保つ */
export const mobileColumnOrder = (
columnCount: number,
bestColumn: number | null,
): number[] => {
const inputOrder = Array.from({ length: columnCount }, (_, index) => index);
if (bestColumn === null) return inputOrder;
return [
bestColumn,
...inputOrder.filter((columnIndex) => columnIndex !== bestColumn),
];
};
const evidenceLabel = (kind: EvidenceKind): string =>
kind === "supporting" ? "加点" : kind === "counter" ? "非加点" : "参考";
const ValueMark = ({ value }: { value: boolean | string }) =>
typeof value === "boolean" ? (
value ? (
<span
aria-label="対応"
data-comparison-mark="positive"
className="text-success"
>
○
</span>
) : (
<span
aria-label="非対応"
data-comparison-mark="negative"
className="text-error"
>
×
</span>
)
) : (
<span>{value}</span>
);
export default function ComparisonTable({
title,
subtitle,
columns,
rows,
}: ComparisonTableProps) {
const autoId = useId();
const uid =
autoId.replace(
// 捨てる操作は単射でないので、schema を通る別々の値が同じ id になる
// (`sales:a` と `sales/a` がどちらも `salesa`)。同じ文書へ 2 つ置くと
// id と radio の name が衝突して、片方の操作がもう片方に効く。
// 逃がし文字(`_`)自身も逃がすので、この変換は単射。
/[^a-zA-Z0-9-]/g,
(c) => `_${c.charCodeAt(0).toString(16)}_`,
) || "comparison-table";
const scores = scoreColumns(rows, columns.length);
const best = bestColumnIndex(scores);
const totals = booleanRowCounts(rows, columns.length);
const notes = rows.filter((row) => row.note);
const mobileOrder = mobileColumnOrder(columns.length, best);
const interactionCss: string = rows
.map((_, rowIndex) => {
const code = criterionCode(rowIndex);
return `
[data-comparison-root="${uid}"]:has([data-comparison-hotspot="${code}"]:is(:hover,:focus)) [data-comparison-criterion-label="${code}"] {
background-color: color-mix(in srgb, var(--color-primary) 7%, var(--color-surface));
box-shadow: inset var(--spacing) 0 0 var(--color-primary);
color: var(--color-primary);
}
[data-comparison-root="${uid}"]:has([data-comparison-hotspot="${code}"]:is(:hover,:focus)) [data-comparison-surface="${code}"] {
box-shadow: inset var(--spacing) 0 0 var(--color-primary);
}`;
})
.join("\n");
return (
<section
data-comparison-root={uid}
className="mx-auto max-w-slide bg-surface p-5 font-sans text-on-surface sm:p-12"
>
<style>{interactionCss}</style>
<header className="mb-7">
<h1 className="text-h1">{title}</h1>
{subtitle && (
<p className="mt-2 text-body-sm text-secondary">{subtitle}</p>
)}
</header>
<div data-comparison-view="desktop" className="hidden sm:block">
<table className="w-full table-fixed border-collapse border border-outline text-body-sm">
<thead>
<tr>
<th className="w-1/3 border border-outline bg-primary p-3 text-left text-on-primary">
<span className="block font-mono text-label opacity-70">
EVIDENCE
</span>
<span className="mt-1 block text-label">評価軸</span>
</th>
{columns.map((col, ci) => (
<th
key={ci}
data-comparison-column={columnCode(ci)}
data-comparison-spine={ci === best ? "header" : undefined}
data-priority={ci === best ? "true" : undefined}
className={`border border-outline p-3 text-center ${
ci === best
? `${HIGHLIGHT_GRADIENT} text-on-tertiary`
: "bg-primary text-on-primary"
}`}
>
<span className="block font-mono text-label opacity-70">
{columnCode(ci)}
</span>
<span className="mt-1 block text-label">{col}</span>
{ci === best && (
<span className="mt-2 block border-t border-on-tertiary/30 pt-2 text-label">
おすすめ · {formatTally(scores[ci], totals[ci])}
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr
key={ri}
data-comparison-row={criterionCode(ri)}
className={
ri % 2 === 1 ? "bg-surface-subtle" : "bg-surface"
}
>
<th
scope="row"
data-comparison-criterion-label={criterionCode(ri)}
className="border border-outline p-3 text-left font-normal text-secondary transition-colors duration-150"
>
<span className="block font-mono text-label text-tertiary">
{criterionCode(ri)}
</span>
<span className="mt-1 block">{row.criterion}</span>
</th>
{row.values.map((value, ci) => {
const kind = evidenceKind(value);
const isBest = ci === best;
return (
<td
key={ci}
data-comparison-column={columnCode(ci)}
data-comparison-spine={isBest ? "body" : undefined}
data-comparison-surface={
isBest ? criterionCode(ri) : undefined
}
className={`border border-outline p-3 text-center ${
isBest
? "relative bg-tertiary-container/30 before:absolute before:inset-y-0 before:left-0 before:w-1 before:bg-tertiary before:content-['']"
: ""
}`}
>
{isBest ? (
<div
tabIndex={0}
data-comparison-evidence={kind}
data-comparison-hotspot={criterionCode(ri)}
className="flex min-h-12 items-center justify-between gap-2 text-left outline-none transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tertiary"
>
<span className="text-body">
<ValueMark value={value} />
</span>
<span
className={`text-label ${
kind === "supporting"
? "text-success"
: kind === "counter"
? "text-error"
: "text-secondary"
}`}
>
{evidenceLabel(kind)}
</span>
</div>
) : (
<ValueMark value={value} />
)}
</td>
);
})}
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-t-outline">
<th
scope="row"
className="border border-outline bg-surface p-3 text-left text-label text-secondary"
>
○の合計
</th>
{columns.map((_, ci) => (
<td
key={ci}
data-comparison-column={columnCode(ci)}
data-comparison-spine={ci === best ? "footer" : undefined}
className={`border border-outline p-3 text-center text-label ${
ci === best
? "relative bg-tertiary-container/30 text-tertiary before:absolute before:inset-y-0 before:left-0 before:w-1 before:bg-tertiary before:content-['']"
: "text-secondary"
}`}
>
{formatTally(scores[ci], totals[ci])}
</td>
))}
</tr>
</tfoot>
</table>
</div>
<div data-comparison-view="mobile" className="sm:hidden">
{best !== null && (
<section
data-comparison-mobile-summary="true"
data-comparison-column={columnCode(best)}
className={`${HIGHLIGHT_GRADIENT} mb-5 p-5 text-on-tertiary`}
>
<p className="font-mono text-label opacity-70">
RECOMMENDATION · {columnCode(best)}
</p>
<div className="mt-2 flex items-end justify-between gap-4">
<h2 className="text-h2">{columns[best]}</h2>
<p className="font-mono text-body">
○ {formatTally(scores[best], totals[best])}
</p>
</div>
</section>
)}
<div className="border-y border-outline">
{rows.map((row, rowIndex) => {
const rowCode = criterionCode(rowIndex);
return (
<article
key={rowIndex}
data-comparison-mobile-criterion={rowCode}
className="border-b border-outline py-4 last:border-b-0"
>
<header className="mb-3 flex items-baseline gap-3">
<span className="font-mono text-label text-tertiary">
{rowCode}
</span>
<h3 className="text-label">{row.criterion}</h3>
</header>
<div className="grid gap-2">
{mobileOrder.map((columnIndex) => {
const value = row.values[columnIndex];
const kind = evidenceKind(value);
const isBest = columnIndex === best;
return (
<div
key={columnIndex}
data-comparison-mobile-option={columnIndex}
data-comparison-column={columnCode(columnIndex)}
data-recommended={isBest ? "true" : undefined}
className={`grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 ${
isBest
? "relative bg-tertiary-container/30 before:absolute before:inset-y-0 before:left-0 before:w-1 before:bg-tertiary before:content-['']"
: "bg-surface-subtle"
}`}
>
<div className="min-w-0">
<p className="font-mono text-label text-tertiary">
{columnCode(columnIndex)}
{isBest && (
<span className="ml-2 text-secondary">
推奨列
</span>
)}
</p>
<p className="mt-1 truncate text-body-sm">
{columns[columnIndex]}
</p>
</div>
<div className="text-right">
<p className="text-body">
<ValueMark value={value} />
</p>
{isBest && (
<p
data-comparison-evidence={kind}
className={`mt-1 text-label ${
kind === "supporting"
? "text-success"
: kind === "counter"
? "text-error"
: "text-secondary"
}`}
>
{evidenceLabel(kind)}
</p>
)}
</div>
</div>
);
})}
</div>
</article>
);
})}
</div>
<div className="mt-4 flex items-center justify-between border-b border-outline pb-4 text-label">
<span className="text-secondary">○の合計</span>
<span className="font-mono text-tertiary">
{best === null
? "推奨なし"
: `${columns[best]} · ${formatTally(scores[best], totals[best])}`}
</span>
</div>
</div>
<p className="mt-3 text-label text-secondary">
○の数が最多の列を自動判定。加点・非加点・参考値を同じ推奨spineで確認
</p>
{notes.length > 0 && (
<details
data-inline-disclosure="true"
className="group mt-6 rounded-lg border border-outline bg-surface-subtle"
>
<summary className="flex cursor-pointer list-none items-center px-4 py-3 text-label text-secondary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tertiary">
<span>補足を確認</span>
<span className="ml-auto font-mono">{notes.length}件</span>
<span
aria-hidden="true"
className="ml-2 text-tertiary transition-transform group-open:rotate-45"
>
+
</span>
</summary>
<div className="grid gap-3 border-t border-outline p-4 sm:grid-cols-2">
{notes.map((row, index) => (
<article
key={index}
data-note-item="true"
className="rounded-md bg-surface p-4"
>
<h3 className="text-label">{row.criterion}</h3>
<p className="mt-2 text-body-sm">{row.note}</p>
</article>
))}
</div>
</details>
)}
</section>
);
}
/designs/comparison-table/source/schema.ts
import { z } from "zod";
export const comparisonRowSchema = z
.object({
criterion: z.string(),
values: z
.array(z.union([z.boolean(), z.string()]))
.describe("列数分の値。boolean は ○/× マーク、string は自由記述で表示"),
note: z
.string()
.optional()
.describe("表の下の補足欄で明示的に展開される補足"),
})
.strict();
export const schema = z
.object({
title: z.string().describe("スライドタイトル。結論を書くメッセージラインを推奨"),
subtitle: z.string().optional().describe("補足・出典など"),
columns: z.array(z.string()).describe("比較対象(列見出し)"),
rows: z.array(comparisonRowSchema),
})
.strict()
.refine((data) => data.rows.every((row) => row.values.length === data.columns.length), {
message: "各行の values 数は columns 数と一致すること",
path: ["rows"],
})
.refine(
(data) =>
data.rows.every((row) =>
row.values.every((v) => typeof v === typeof row.values[0]),
),
{
message:
"1行の中で boolean と string を混在させないこと(列ごとのboolean行数がずれ、集計フッターとベスト列の強調表示が矛盾するため)",
path: ["rows"],
},
);
export type ComparisonRow = z.infer<typeof comparisonRowSchema>;
export type ComparisonTableProps = z.infer<typeof schema>;
/designs/comparison-table/source/meta.yaml
id: comparison-table
name:
ja: "比較表(製品・ベンダー選定)"
en: "Comparison Table (Product / Vendor Selection)"
purpose:
ja: "製品・ベンダーを評価軸ごとに比較する表。○ の数が最も多い列をデータから自動判定し、見出し・各評価の加点/非加点/参考・集計を一続きの推奨 spine で示す"
en: "Candidates compared criterion by criterion. The column with the most ticks is found from the data, and the heading, each mark and the tally sit on one recommendation spine"
status: published
# 索引カードの図版。pnpm layout:render が原本から描く。
still: ./still.png
category: comparison
catalogOrder: 20
jobs:
- compare-choose
useCases:
- comparison
layoutKinds:
- table
tags: [table, comparison, vendor-selection, data-driven, interactive]
whenToUse:
- ja: "製品・ベンダー・ツールの選定比較"
en: "Choosing between products, vendors or tools"
- ja: "複数案の評価軸別の優劣整理"
en: "Sorting out how several options fare on each criterion"
- ja: "見積もり・契約条件の比較"
en: "Comparing quotes and contract terms"
avoidWhen:
- ja: "2 軸上の位置関係や優先度を示したい場合"
en: "Showing position or priority on two axes"
- ja: "行と列の全交点を数値の濃淡で診断したい場合"
en: "Diagnosing every row-column intersection by shade"
requiredInputs:
- ja: "比較対象"
en: "The things being compared"
- ja: "全対象に共通する評価軸"
en: "Criteria that apply to all of them"
- ja: "評価軸ごとの値または可否"
en: "A value or a yes/no for each criterion"
relatedLayouts: [matrix-2x2, heatmap, raci-matrix]
dataSchema:
title: "string — スライドタイトル。結論を書くメッセージラインを推奨"
subtitle: "string(任意)— 補足・出典など"
columns: "string[] — 比較対象(列見出し)"
rows: "{ criterion, values: (boolean|string)[], note? } の配列。values は列数分。boolean は ○/× マーク、string は自由記述。true の数が最多の列を自動推奨し、その列の boolean は加点/非加点、string は参考として表示。note は表の下の補足欄で展開"
sources: []
/designs/comparison-table/source/sample-data.json
{
"title": "グループウェア3製品比較 — 総合力でB社が優勢",
"subtitle": "○の数が最も多い列を自動判定して強調表示(2026年度 情報システム部門・選定サンプル)",
"columns": ["A社(現行)", "B社", "C社"],
"rows": [
{
"criterion": "初期費用",
"values": ["¥0", "¥50,000", "¥30,000"],
"note": "B社・C社は導入時の初期設定支援費用を含む"
},
{
"criterion": "月額料金(1ユーザ)",
"values": ["¥500", "¥800", "¥600"]
},
{
"criterion": "SSO対応",
"values": [true, true, false]
},
{
"criterion": "モバイルアプリ",
"values": [false, true, true]
},
{
"criterion": "SLA 99.9%以上",
"values": [false, true, false],
"note": "計画メンテナンス時間を除いた月間稼働率の保証水準"
},
{
"criterion": "日本語サポート窓口",
"values": [true, true, false]
}
]
}