BizLayout

型番 comparison-table

比較表(製品・ベンダー選定)

製品・ベンダーを評価軸ごとに比較する表。○の数が最も多い列をデータから自動判定し、見出し・各評価の加点/非加点/参考・集計を一続きの推奨spineで示す。390pxでは評価軸ごとの全候補ledgerへ転置し、補足は表の下で明示的に展開できる

table · comparison · vendor-selection · data-driven · interactive

LIVE SAMPLE

プレビュー

補足の開閉・表示切替を試せます

グループウェア3製品比較 — 総合力でB社が優勢

○の数が最も多い列を自動判定して強調表示(2026年度 情報システム部門・選定サンプル)

RECOMMENDATION · C02

B社

4/4

E01

初期費用

C02推奨列

B社

¥50,000

参考

C01

A社(現行)

¥0

C03

C社

¥30,000

E02

月額料金(1ユーザ)

C02推奨列

B社

¥800

参考

C01

A社(現行)

¥500

C03

C社

¥600

E03

SSO対応

C02推奨列

B社

加点

C01

A社(現行)

C03

C社

×

E04

モバイルアプリ

C02推奨列

B社

加点

C01

A社(現行)

×

C03

C社

E05

SLA 99.9%以上

C02推奨列

B社

加点

C01

A社(現行)

×

C03

C社

×

E06

日本語サポート窓口

C02推奨列

B社

加点

C01

A社(現行)

C03

C社

×

○の合計B社 · 4/4

○の数が最多の列を自動判定。加点・非加点・参考値を同じ推奨spineで確認

補足を確認2

初期費用

B社・C社は導入時の初期設定支援費用を含む

SLA 99.9%以上

計画メンテナンス時間を除いた月間稼働率の保証水準

こういうときに使う

  • 製品・ベンダー・ツールの選定比較
  • 複数案の評価軸別の優劣整理
  • 見積もり・契約条件の比較

ALTERNATIVES

条件を変えて探す →

データスキーマ

title
string — スライドタイトル。結論を書くメッセージラインを推奨
subtitle
string(任意)— 補足・出典など
columns
string[] — 比較対象(列見出し)
rows
{ criterion, values: (boolean|string)[], note? } の配列。values は列数分。boolean は ○/× マーク、string は自由記述。true の数が最多の列を自動推奨し、その列のbooleanは加点/非加点、stringは参考として表示。note は表の下の補足欄で展開

サンプルコード

template.tsx412 行
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(/[^a-zA-Z0-9_-]/g, "") || "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>
  );
}
schema.ts41 行
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>;
sample-data.json33 行
{
  "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]
    }
  ]
}