L-009 Layout medium

How to build a responsive comparison table レスポンシブ比較表の作り方

Compare multiple plans or products row by row in a table that scrolls horizontally on mobile instead of breaking. 複数のプランや製品を項目ごとに比較する表。モバイルではレイアウト崩れの代わりに横スクロールします。

ライブデモ Live Demo

料金プラン比較表。Starter・Growth・Scale の3プランを機能ごとに比較します。
機能 Feature Starter Growth 人気 Popular Scale
シート数 Seats 3 15 無制限 Unlimited
プロジェクト数 Projects 5 50 無制限 Unlimited
ストレージ Storage 10GB 100GB 1TB
API アクセス API Access なしNo ありYes ありYes
サポート Support Email Email + Chat 24/7
料金(月額) Price / mo $12 $49 $199

※ 画面が狭い場合は表を左右にスワイプできます。

※ Swipe left or right on narrow screens to see all columns.

概要・用途・特徴Overview, Usage & Features

何ができるかWhat it does

A <table>-based layout that compares several plans or products across multiple feature rows, with proper column and row headers for accessibility.

複数のプランや製品を機能ごとの行で比較する<table>ベースのレイアウトです。アクセシビリティのため、正しい列・行見出しを持ちます。

どこで使うかWhere to use

Use it on pricing pages, feature comparison pages, or any page that needs to show multiple options side by side without breaking on mobile.

料金ページや機能比較ページなど、複数の選択肢を並べて見せたいがモバイルでレイアウトを崩したくないページに使います。

特徴Key features

Semantic <table> with scope="col" / scope="row" headers, a visually hidden <caption> describing the table, a scrollable wrapper with role="group" and tabindex="0" for keyboard users, a sticky first column toggle, and a highlighted column to guide the chosen plan.

scope="col" / scope="row" を持つセマンティックな<table>、表の内容を説明する視覚的に隠された<caption>、キーボード操作のための role="group" と tabindex="0" 付きスクロールラッパー、先頭列の固定表示切り替え、注目プランを示すハイライト列。

調整可能パラメータ Adjustable Parameters

Parameter Default Description
--cmp-accent#5c6ac4Accent color for the highlighted column background and the plan badge.
data-highlightgrowthWhich column key (none / starter / growth / scale in the demo) is highlighted.
is-sticky-off(absent)Add this class to .cmp-table-scroll to disable the sticky first column.

実装コード Implementation Code

<div class="cmp-table-wrap">
  <div class="cmp-table-scroll" id="cmp_scroll" tabindex="0" role="group" aria-label="Plan comparison table">
    <table class="cmp-table" id="cmp_table" data-highlight="growth">
      <caption class="cmp-table__sr">Plan comparison table</caption>
      <thead>
        <tr>
          <th scope="col" class="cmp-table__head-col">Feature</th>
          <th scope="col" data-col="starter">Starter</th>
          <th scope="col" data-col="growth">Growth</th>
          <th scope="col" data-col="scale">Scale</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <th scope="row" class="cmp-table__head-col">Seats</th>
          <td data-col="starter">3</td>
          <td data-col="growth">15</td>
          <td data-col="scale">Unlimited</td>
        </tr>
        <!-- more rows -->
      </tbody>
    </table>
  </div>
</div>

<script>
const table = document.getElementById('cmp_table');
const cells = table.querySelectorAll('[data-col]');

function applyHighlight(col) {
  cells.forEach((cell) => {
    cell.classList.toggle('is-highlighted', cell.getAttribute('data-col') === col);
  });
}
applyHighlight(table.getAttribute('data-highlight'));

document.getElementById('ctrl_highlight')?.addEventListener('change', (e) => {
  table.setAttribute('data-highlight', e.target.value);
  applyHighlight(e.target.value);
});

document.getElementById('ctrl_sticky')?.addEventListener('change', (e) => {
  document.getElementById('cmp_scroll').classList.toggle('is-sticky-off', !e.target.checked);
});
</script>
.cmp-table-wrap {
  border: 1px solid var(--border);
  border-radius: 14px;
  overflow: hidden;
  background: var(--panel-bg);
}

.cmp-table-scroll {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

.cmp-table-scroll:focus-visible {
  outline: 2px solid var(--cmp-accent);
  outline-offset: -2px;
}

.cmp-table {
  width: 100%;
  min-width: 560px;
  border-collapse: collapse;
  font-size: 14px;
}

.cmp-table__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.cmp-table th,
.cmp-table td {
  padding: 14px 18px;
  text-align: left;
  border-bottom: 1px solid var(--border);
  min-width: 150px;
  white-space: nowrap;
}

.cmp-table thead th {
  font-size: 13px;
  font-weight: 700;
  background: var(--surface);
}

.cmp-table tbody th[scope="row"] {
  font-weight: 600;
  color: var(--muted);
  white-space: normal;
}

.cmp-table-scroll:not(.is-sticky-off) .cmp-table__head-col {
  position: sticky;
  left: 0;
  z-index: 2;
  background: var(--panel-bg);
  box-shadow: 2px 0 6px rgba(0, 0, 0, 0.08);
}

.cmp-table-scroll:not(.is-sticky-off) thead .cmp-table__head-col {
  background: var(--surface);
  z-index: 3;
}

.cmp-table td.is-highlighted,
.cmp-table th.is-highlighted {
  background: color-mix(in srgb, var(--cmp-accent) 12%, var(--panel-bg));
}

.cmp-table thead th.is-highlighted {
  background: color-mix(in srgb, var(--cmp-accent) 22%, var(--surface));
  color: var(--cmp-accent);
}

.cmp-table tbody tr:last-child th,
.cmp-table tbody tr:last-child td {
  border-bottom: none;
}
import { useMemo, useState } from 'react';
import './L-009.css';

const COLUMNS = [
  { key: 'starter', label: 'Starter' },
  { key: 'growth', label: 'Growth', badge: 'Popular' },
  { key: 'scale', label: 'Scale' },
];

const ROWS = [
  { label: 'Seats', values: { starter: '3', growth: '15', scale: 'Unlimited' } },
  { label: 'Projects', values: { starter: '5', growth: '50', scale: 'Unlimited' } },
  { label: 'Storage', values: { starter: '10GB', growth: '100GB', scale: '1TB' } },
  { label: 'Support', values: { starter: 'Email', growth: 'Email + Chat', scale: '24/7' } },
  { label: 'Price / mo', values: { starter: '$12', growth: '$49', scale: '$199' } },
];

export default function ComparisonTable({ highlight = 'growth', sticky = true }) {
  const [active, setActive] = useState(highlight);
  const scrollClass = useMemo(() => (sticky ? 'cmp-table-scroll' : 'cmp-table-scroll is-sticky-off'), [sticky]);

  return (
    <div className="cmp-table-wrap">
      <div className={scrollClass} tabIndex={0} role="group" aria-label="Plan comparison table">
        <table className="cmp-table">
          <caption className="cmp-table__sr">Plan comparison table</caption>
          <thead>
            <tr>
              <th scope="col" className="cmp-table__head-col">Feature</th>
              {COLUMNS.map((col) => (
                <th
                  key={col.key}
                  scope="col"
                  className={['cmp-table__col-head', col.key === active && 'is-highlighted'].filter(Boolean).join(' ')}
                >
                  <button
                    type="button"
                    className="cmp-table__col-btn"
                    aria-pressed={col.key === active}
                    onClick={() => setActive(col.key)}
                  >
                    {col.label}
                    {col.badge && <span className="cmp-table__plan-badge">{col.badge}</span>}
                  </button>
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {ROWS.map((row) => (
              <tr key={row.label}>
                <th scope="row" className="cmp-table__head-col">{row.label}</th>
                {COLUMNS.map((col) => (
                  <td key={col.key} className={col.key === active ? 'is-highlighted' : undefined}>
                    {row.values[col.key]}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}
/* L-009: React styles */
.cmp-table-wrap {
  border: 1px solid #e0e4f0;
  border-radius: 14px;
  overflow: hidden;
  background: #fff;
}

.cmp-table-scroll {
  overflow-x: auto;
}

.cmp-table-scroll:focus-visible {
  outline: 2px solid #5c6ac4;
  outline-offset: -2px;
}

.cmp-table {
  width: 100%;
  min-width: 560px;
  border-collapse: collapse;
  font-size: 14px;
}

.cmp-table__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.cmp-table th,
.cmp-table td {
  padding: 14px 18px;
  text-align: left;
  border-bottom: 1px solid #e0e4f0;
  min-width: 150px;
  white-space: nowrap;
}

.cmp-table thead th {
  font-size: 13px;
  font-weight: 700;
  background: #f7f8fc;
}

.cmp-table th.cmp-table__col-head {
  padding: 0;
}

.cmp-table__col-btn {
  all: unset;
  box-sizing: border-box;
  cursor: pointer;
  display: flex;
  align-items: center;
  width: 100%;
  height: 100%;
  padding: 14px 18px;
  font: inherit;
  color: inherit;
}

.cmp-table__col-btn:focus-visible {
  outline: 2px solid #5c6ac4;
  outline-offset: 2px;
  border-radius: 4px;
}

.cmp-table tbody th[scope='row'] {
  font-weight: 600;
  color: #5c6184;
  white-space: normal;
}

.cmp-table-scroll:not(.is-sticky-off) .cmp-table__head-col {
  position: sticky;
  left: 0;
  z-index: 2;
  background: #fff;
  box-shadow: 2px 0 6px rgba(0, 0, 0, 0.08);
}

.cmp-table-scroll:not(.is-sticky-off) thead .cmp-table__head-col {
  background: #f7f8fc;
  z-index: 3;
}

.cmp-table__plan-badge {
  display: inline-block;
  margin-left: 8px;
  padding: 2px 8px;
  border-radius: 20px;
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  background: #5c6ac4;
  color: #fff;
}

.cmp-table td.is-highlighted,
.cmp-table th.is-highlighted {
  background: rgba(92, 106, 196, 0.12);
}

.cmp-table thead th.is-highlighted {
  background: rgba(92, 106, 196, 0.22);
  color: #5c6ac4;
}

.cmp-table tbody tr:last-child th,
.cmp-table tbody tr:last-child td {
  border-bottom: none;
}

仕組みとカスタマイズHow It Works & Customization

仕組みHow it works

The table is wrapped in a <div class="cmp-table-scroll"> with overflow-x: auto, so on narrow viewports the user can swipe to see every column instead of the table squeezing itself unreadable. Each data cell carries a data-col attribute (e.g. data-col="growth"); a small script toggles an is-highlighted class on every cell whose data-col matches the current selection, driven by the table's data-highlight attribute. The first column (both the header <th scope="col"> and each row's <th scope="row">) shares a .cmp-table__head-col class that becomes position: sticky; left: 0 unless the wrapper carries is-sticky-off.

表は overflow-x: auto を持つ <div class="cmp-table-scroll"> でラップされているため、狭い画面でもスワイプして全列を確認でき、表が潰れて読めなくなることがありません。各データセルには data-col 属性(例: data-col="growth")が付いており、小さなスクリプトが table の data-highlight 属性と一致するセルに is-highlighted クラスを切り替えます。先頭列(ヘッダーの <th scope="col"> と各行の <th scope="row">)は共通の .cmp-table__head-col クラスを持ち、ラッパーに is-sticky-off が付いていない限り position: sticky; left: 0 になります。

カスタマイズ方法Customization

Swap --cmp-accent to match your brand color. Change the default highlighted column via the table's data-highlight attribute. Add more rows freely — each is just a <tr> with one <th scope="row"> and N <td> cells. Split a long table into multiple <tbody> groups to add category sub-headings.

--cmp-accent をブランドカラーに差し替えます。初期状態でハイライトする列は table の data-highlight 属性で変更できます。行は <th scope="row"> と N個の <td> を持つ <tr> を追加するだけで自由に増やせます。長い表はカテゴリ見出しを挟むために複数の <tbody> に分割できます。

注意点Caveats

Always pair checkmarks or color-only indicators with text (Yes/No, あり/なし) so screen reader users and users with color vision differences get the same information. Keep min-width on cells reasonable so the table does not force excessive scrolling on desktop. Test keyboard scrolling (Tab to the wrapper, then arrow keys) since role="group" plus tabindex="0" is what makes that possible.

チェックマークや色だけに頼らず、必ずテキスト(あり/なし、Yes/No)を併記してスクリーンリーダー利用者や色覚特性のあるユーザーにも同じ情報を伝えてください。セルの min-width は過度にならない値にし、デスクトップで不要なスクロールを強制しないようにします。role="group" と tabindex="0" によってキーボードでのスクロールが可能になるため、Tabでラッパーにフォーカスしてから矢印キーで動作するか確認してください。

よくある質問 Frequently Asked Questions

How do I keep the table accessible to screen readers? スクリーンリーダーに対してどうアクセシブルにしますか?

Use <th scope="col"> for column headers and <th scope="row"> for the first column of each row, add a <caption> describing the table (visually hidden if needed), and never convey information with color or a checkmark alone — always include text.

列見出しには<th scope="col">、各行の先頭列には<th scope="row">を使い、表の内容を説明する<caption>を追加してください(必要なら視覚的に隠します)。色やチェックマークだけで情報を伝えず、必ずテキストを併記してください。

How does it behave on narrow screens? 狭い画面ではどう表示されますか?

The table sits inside a wrapper with overflow-x: auto, so it scrolls horizontally instead of squeezing columns unreadable. The wrapper has role="group" and tabindex="0" so keyboard users can scroll it too.

表は overflow-x: auto を持つラッパーの中にあるため、列が潰れる代わりに横スクロールします。ラッパーには role="group" と tabindex="0" が付いているため、キーボード操作でもスクロールできます。

How do I use it in React? Reactで使うには?

Import the provided component and its CSS file. Pass highlight to set which column key starts highlighted, and sticky (default true) to control whether the first column pins while scrolling.

提供されているコンポーネントとCSSファイルをインポートしてください。highlight でどの列を初期状態でハイライトするか、sticky(デフォルト true)で先頭列を固定するかを指定できます。

AIへの指示テンプレート AI Prompt Template

以下をAIに貼り付けると実装を依頼できます。 Paste the following into your AI assistant to request implementation.