First CH Designs
BLK-33Blocks

料金②機能比較マトリクス

プラン×機能を1枚の表で見せる料金ブロック。推奨プランの列だけ薄いアンバー地とバッジで縦に貫き、対応は細線チェック・非対応は淡いダッシュに落として表全体の濃度を均した。価格やページ数は tabular-nums で桁を揃え、狭い画面では先頭列を sticky にしたまま横スワイプで比較できる。

追加日:
2026-08-21
依存:
なし
tags
#pricing #table #comparison #section #brand #responsive

プレビュー

Plan comparison

プランごとの対応範囲

表示価格は税別・初期制作費の目安です。

ライト・スタンダード・プレミアムの3プランと、制作・運用・サポート各項目の対応表
機能・サポートライト¥10万〜小規模サイト向けおすすめスタンダード¥30万〜本格サイト向けプレミアム¥50万〜伴走型の制作
制作・実装
ページ数5ページ10ページ無制限
スマホ最適化対応対応対応
オリジナルデザインテンプレート非使用非対応対応対応
撮影・原稿作成非対応非対応対応
運用・機能
更新機能(CMS)非対応対応対応
お問い合わせフォーム1件3件無制限
お知らせ・ブログ非対応対応対応
多言語ページ非対応非対応対応
サポート
公開後サポート1ヶ月3ヶ月12ヶ月
アクセス解析レポート非対応非対応月1回
定例ミーティング非対応隔月毎月
ご要望に応じて項目単位の追加も承ります。相談する相談する相談する

← 横にスワイプして全プランを比較

"use client";

import { useEffect, useRef, useState } from "react";

/**
 * 料金②機能比較マトリクス(BLK-33)
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 *
 * 1. 比較表は「カード羅列」ではなく1枚の table で組む
 *    プラン×機能は本質的に2次元データなので、<table> + scope 属性で組むと
 *    スクリーンリーダーが「ライト / CMS導入 / 非対応」と読み上げてくれる。
 *    div グリッドで作ると行と列の対応が音声で失われる。
 *    グループ見出しは <tbody> を分けて <th scope="colgroup"> を立てる。
 *
 * 2. 推奨列のハイライトは「セル単位のクラス」で縦に貫く
 *    列全体に色を当てる CSS(<col> の background)は border-collapse 下で
 *    ボーダーとの重なり順が不安定なので使わない。各行のそのセルへ
 *    bg-amber-50/70 + border-x border-amber-200 を配り、
 *    最上段に border-t、最下段に border-b を足すと1本の柱になる。
 *    グループ見出し行にも空セルを置いて柱を切らさないのが肝。
 *
 * 3. 横スワイプは overflow-x-auto + 先頭列 sticky
 *    表は table-fixed + min-w で列幅を固定する(auto レイアウトだと狭い幅で
 *    先頭列だけが伸び、値の列が枠外へ押し出されて読めなくなる)。包む div に overflow-x-auto。
 *    機能名の列(先頭列)だけ sticky left-0 + bg-white にすると、
 *    横スクロール中も「何の行か」が消えない。bg-white は横スクロールが起きる幅でだけ要るので、
 *    表が収まるコンテナ幅では @2xl:bg-transparent で戻す(白い帯が地に浮くのを避ける)。
 *    スクロール領域は tabIndex={0} + role="region" にしてキーボードでも送れるようにする
 *    (ブラウザの「スクロール可能領域はフォーカス可能に」の要件)。
 *
 * 4. 記号の設計: 対応=細線チェック / 非対応=淡いダッシュ
 *    塗りのチェックと ✕ を並べると「✕」が強すぎて非対応が欠点に見える。
 *    stroke-width 1.25 の細線チェックと、h-px の短い罫線(ダッシュ)に落とすと
 *    表全体の濃度が均され、罫線と余白のグルーピングが主役になる。
 *    記号だけでは音声で伝わらないので sr-only で「対応 / 非対応」を必ず添える。
 *
 * 5. 数字は tabular-nums
 *    価格・ページ数など桁が変わる値は font-variant-numeric: tabular-nums(Tailwind の
 *    tabular-nums)で字送りを固定する。列で縦に揃うと比較の視線移動が減る。
 *
 * 6. モーションは行単位のスタッガー1回だけ
 *    IntersectionObserver で1度だけ可視化し、transform/opacity のみを
 *    行インデックス×40ms でずらす。motion-reduce: で初期状態を打ち消して即時表示にする
 *    (transition-none だけでは opacity-0 のまま固まる)。
 */

type Cell = boolean | string;

type Plan = {
  key: string;
  name: string;
  price: string;
  unit: string;
  lead: string;
  featured?: boolean;
};

type Group = {
  label: string;
  rows: { label: string; note?: string; cells: [Cell, Cell, Cell] }[];
};

const plans: Plan[] = [
  { key: "light", name: "ライト", price: "10", unit: "万〜", lead: "小規模サイト向け" },
  {
    key: "standard",
    name: "スタンダード",
    price: "30",
    unit: "万〜",
    lead: "本格サイト向け",
    featured: true,
  },
  { key: "premium", name: "プレミアム", price: "50", unit: "万〜", lead: "伴走型の制作" },
];

const groups: Group[] = [
  {
    label: "制作・実装",
    rows: [
      { label: "ページ数", cells: ["5ページ", "10ページ", "無制限"] },
      { label: "スマホ最適化", cells: [true, true, true] },
      { label: "オリジナルデザイン", note: "テンプレート非使用", cells: [false, true, true] },
      { label: "撮影・原稿作成", cells: [false, false, true] },
    ],
  },
  {
    label: "運用・機能",
    rows: [
      { label: "更新機能(CMS)", cells: [false, true, true] },
      { label: "お問い合わせフォーム", cells: ["1件", "3件", "無制限"] },
      { label: "お知らせ・ブログ", cells: [false, true, true] },
      { label: "多言語ページ", cells: [false, false, true] },
    ],
  },
  {
    label: "サポート",
    rows: [
      { label: "公開後サポート", cells: ["1ヶ月", "3ヶ月", "12ヶ月"] },
      { label: "アクセス解析レポート", cells: [false, false, "月1回"] },
      { label: "定例ミーティング", cells: [false, "隔月", "毎月"] },
    ],
  },
];

function CheckMark({ featured }: { featured?: boolean }) {
  return (
    <>
      <svg
        viewBox="0 0 20 20"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.25}
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
        className={`mx-auto size-[18px] ${featured ? "text-amber-600" : "text-gray-700"}`}
      >
        <path d="M4 10.4 8.2 14.6 16 5.8" />
      </svg>
      <span className="sr-only">対応</span>
    </>
  );
}

function DashMark() {
  return (
    <>
      <span className="mx-auto block h-px w-3.5 bg-gray-300" aria-hidden="true" />
      <span className="sr-only">非対応</span>
    </>
  );
}

export default function PricingCompareTable() {
  const rootRef = useRef<HTMLElement | null>(null);
  const [inView, setInView] = useState(false);

  useEffect(() => {
    const node = rootRef.current;
    if (!node) return;
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((entry) => entry.isIntersecting)) {
          setInView(true);
          io.disconnect();
        }
      },
      { threshold: 0.1 },
    );
    io.observe(node);
    return () => io.disconnect();
  }, []);

  const reveal = (index: number) =>
    ({
      transitionDelay: `${120 + index * 40}ms`,
    }) as const;

  const revealClass = `transition-[opacity,transform] duration-500 ease-out motion-reduce:transition-none motion-reduce:translate-y-0 motion-reduce:opacity-100 ${
    inView ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0"
  }`;

  /** 推奨列を縦に貫くアンバーの柱(セル単位で配る) */
  const col = (plan: Plan) =>
    plan.featured ? "border-x border-amber-200 bg-amber-50/70" : "";

  let rowIndex = 0;

  return (
    <section
      ref={rootRef}
      className="@container w-full max-w-[720px] text-gray-900"
      aria-labelledby="cmp-title"
    >
      <p className="text-[11px] font-bold tracking-[0.28em] text-amber-600 uppercase">
        Plan comparison
      </p>
      <div className="mt-3 flex flex-col gap-2 @xl:flex-row @xl:items-end @xl:justify-between">
        <h2 id="cmp-title" className="text-2xl font-bold tracking-tight @xl:text-[28px]">
          プランごとの対応範囲
        </h2>
        <p className="text-xs leading-relaxed text-gray-500">
          表示価格は税別・初期制作費の目安です。
        </p>
      </div>

      <div
        role="region"
        aria-label="料金プラン比較表(横スクロールできます)"
        tabIndex={0}
        className="mt-6 overflow-x-auto rounded-xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
      >
        <table className="w-full min-w-[540px] table-fixed border-collapse text-left">
          <caption className="sr-only">
            ライト・スタンダード・プレミアムの3プランと、制作・運用・サポート各項目の対応表
          </caption>
          <thead>
            <tr>
              <th
                scope="col"
                className="sticky left-0 z-10 w-[30%] bg-white @2xl:bg-transparent pb-4 align-bottom text-xs font-semibold text-gray-400"
              >
                機能・サポート
              </th>
              {plans.map((plan, i) => (
                <th
                  key={plan.key}
                  scope="col"
                  style={reveal(i)}
                  className={`w-[23.3%] rounded-t-xl px-3 pt-4 pb-4 text-center align-top ${revealClass} ${
                    plan.featured ? "border-t border-x border-amber-200 bg-amber-50/70" : ""
                  }`}
                >
                  <span className="flex h-5 items-center justify-center">
                    {plan.featured && (
                      <span className="rounded-full bg-amber-600 px-2.5 py-0.5 text-[10px] font-bold tracking-[0.12em] text-white">
                        おすすめ
                      </span>
                    )}
                  </span>
                  <span className="mt-2 block text-sm font-bold text-gray-900">{plan.name}</span>
                  <span className="mt-1.5 flex items-baseline justify-center gap-0.5">
                    <span className="text-xs font-medium text-gray-500">¥</span>
                    <span className="text-2xl font-bold tracking-tight tabular-nums text-gray-900">
                      {plan.price}
                    </span>
                    <span className="text-xs font-medium text-gray-500">{plan.unit}</span>
                  </span>
                  <span className="mt-1 block text-[11px] leading-relaxed font-normal text-gray-500">
                    {plan.lead}
                  </span>
                </th>
              ))}
            </tr>
          </thead>

          {groups.map((group) => (
            <tbody key={group.label}>
              <tr>
                <th
                  scope="colgroup"
                  className="sticky left-0 z-10 bg-white pt-6 @2xl:bg-transparent pb-2 text-[11px] font-bold tracking-[0.16em] text-gray-400 uppercase"
                >
                  <span className="flex items-center gap-2">
                    <span className="h-px w-4 bg-amber-600" aria-hidden="true" />
                    {group.label}
                  </span>
                </th>
                {plans.map((plan) => (
                  <td key={plan.key} className={col(plan)} />
                ))}
              </tr>
              {group.rows.map((row) => {
                const index = rowIndex++;
                return (
                  <tr key={row.label} style={reveal(index)} className={revealClass}>
                    <th
                      scope="row"
                      className="sticky left-0 z-10 border-t border-gray-100 bg-white py-3 @2xl:bg-transparent pr-4 text-[13px] leading-snug font-medium text-gray-700"
                    >
                      {row.label}
                      {row.note && (
                        <span className="mt-0.5 block text-[11px] font-normal text-gray-400">
                          {row.note}
                        </span>
                      )}
                    </th>
                    {row.cells.map((cell, i) => {
                      const plan = plans[i];
                      return (
                        <td
                          key={plan.key}
                          className={`border-t border-gray-100 px-3 py-3 text-center text-[13px] tabular-nums ${
                            typeof cell === "string" ? "font-medium text-gray-800" : ""
                          } ${col(plan)}`}
                        >
                          {cell === true ? (
                            <CheckMark featured={plan.featured} />
                          ) : cell === false ? (
                            <DashMark />
                          ) : (
                            cell
                          )}
                        </td>
                      );
                    })}
                  </tr>
                );
              })}
            </tbody>
          ))}

          <tfoot>
            <tr>
              <td className="sticky left-0 z-10 bg-white pt-5 @2xl:bg-transparent text-[11px] leading-relaxed text-gray-400 align-top">
                ご要望に応じて項目単位の追加も承ります。
              </td>
              {plans.map((plan, i) => (
                <td
                  key={plan.key}
                  style={reveal(rowIndex + i)}
                  className={`rounded-b-xl px-3 pt-5 pb-5 text-center ${revealClass} ${
                    plan.featured ? "border-x border-b border-amber-200 bg-amber-50/70" : ""
                  }`}
                >
                  <a
                    href="#"
                    className={`inline-flex items-center justify-center rounded-lg px-4 py-2 text-xs font-semibold whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 ${
                      plan.featured
                        ? "bg-amber-600 text-white hover:bg-amber-700"
                        : "border border-gray-300 bg-white text-gray-800 hover:bg-gray-50"
                    }`}
                  >
                    <span className="@2xl:hidden">相談する</span>
                    <span className="hidden @2xl:inline">このプランで相談</span>
                  </a>
                </td>
              ))}
            </tr>
          </tfoot>
        </table>
      </div>

      <p className="mt-4 text-[11px] text-gray-400 @2xl:hidden">← 横にスワイプして全プランを比較</p>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/pricing-compare-table.json