TECH · テーブル

狭い画面でも読める表を作る

サンプルとコード

01

横スクロールと見出し列の固定

単独で開く ↗

右にまだ続きがあることを示す影を、CSSだけで出しています。background-attachment の local と scroll の差を使う手法で、JavaScriptは1行もありません。

HTML
<!-- スクロールできる領域はキーボードでも動かせる必要がある。
     tabindex="0" と、何の表かを示す名前を付ける。 -->
<div class="table-scroll" tabindex="0" role="region" aria-label="料金プランの比較(横スクロール)">
  <table>
    <caption>2026年9月現在の月額料金(税込)</caption>
    <thead>
      <tr>
        <!-- 列見出しか行見出しかを scope で明示する。これが無いと
             スクリーンリーダーはセルの対応を推測するしかない。 -->
        <th scope="col">プラン</th>
        <th scope="col">月額</th>
        <th scope="col">保存容量</th>
        <th scope="col">同時接続</th>
        <th scope="col">サポート</th>
        <th scope="col">SLA</th>
        <th scope="col">バックアップ</th>
        <th scope="col">独自ドメイン</th>
        <th scope="col">監査ログ</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">フリー</th>
        <td class="num">¥0</td>
        <td class="num">1 GB</td>
        <td class="num">1</td>
        <td>コミュニティ</td>
        <td>なし</td>
        <td>なし</td>
        <td>不可</td>
        <td>なし</td>
      </tr>
      <tr>
        <th scope="row">スタンダード</th>
        <td class="num">¥1,480</td>
        <td class="num">100 GB</td>
        <td class="num">5</td>
        <td>メール(平日)</td>
        <td>99.5%</td>
        <td>日次・7日保持</td>
        <td>1件</td>
        <td>30日</td>
      </tr>
      <tr>
        <th scope="row">プロ</th>
        <td class="num">¥4,800</td>
        <td class="num">1 TB</td>
        <td class="num">25</td>
        <td>メール(24時間)</td>
        <td>99.9%</td>
        <td>日次・30日保持</td>
        <td>5件</td>
        <td>1年</td>
      </tr>
      <tr>
        <th scope="row">エンタープライズ</th>
        <td class="num">¥19,800</td>
        <td class="num">無制限</td>
        <td class="num">100</td>
        <td>専任担当</td>
        <td>99.99%</td>
        <td>時間単位・90日保持</td>
        <td>無制限</td>
        <td>無期限</td>
      </tr>
    </tbody>
  </table>
</div>
CSS
/* スクロールするのは table ではなく、外側の入れ物。
   table 自体に overflow を掛けても効かない。 */
.table-scroll {
  overflow-x: auto;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: #fff;

  /* 端に影を出して「まだ右がある」ことを見た目で伝える。
     背景を2枚(白のグラデ=マスク/影)重ね、
     background-attachment: local と scroll の差で
     スクロール位置に応じて影が出入りする。JavaScript は要らない。 */
  background-image:
    linear-gradient(to right, #fff 30%, rgba(255, 255, 255, 0)),
    linear-gradient(to left, #fff 30%, rgba(255, 255, 255, 0)),
    linear-gradient(to right, rgba(20, 24, 36, .14), rgba(20, 24, 36, 0)),
    linear-gradient(to left, rgba(20, 24, 36, .14), rgba(20, 24, 36, 0));
  background-position: left center, right center, left center, right center;
  background-repeat: no-repeat;
  background-size: 28px 100%, 28px 100%, 12px 100%, 12px 100%;
  background-attachment: local, local, scroll, scroll;
}

table {
  border-collapse: collapse;
  /* 折り返させない列があるので、中身なりに広がってよい。
     width: 100% にすると狭い画面で潰れて読めなくなる。 */
  min-width: 100%;
  font-size: .88rem;
}

caption {
  caption-side: top;
  text-align: left;
  padding: .8rem .9rem .2rem;
  font-size: .84rem;
  line-height: 1.85;
  color: var(--muted);
}

th, td {
  padding: .6rem .8rem;
  border-bottom: 1px solid var(--line);
  text-align: left;
  white-space: nowrap;
  line-height: 1.8;
}
thead th {
  font-size: .78rem;
  color: var(--muted);
  font-weight: 600;
  background: #fbfcfe;
}
tbody tr:last-child th,
tbody tr:last-child td { border-bottom: 0; }

/* 見出し列を左に貼り付ける。横に流したとき「どの行か」を
   見失わないための処置で、比較表では必須に近い。 */
tbody th[scope="row"],
thead th:first-child {
  position: sticky;
  left: 0;
  background: #fff;
  /* 固定した列と本体の境目。影が無いと重なりが分からない。 */
  box-shadow: 1px 0 0 var(--line);
}
thead th:first-child { background: #fbfcfe; }

/* 数字は等幅の数字書体にする。桁が揃わないと比較できない。 */
td.num { text-align: right; font-variant-numeric: tabular-nums; }
AIへの指示文
横スクロールする比較表を作ってください。
要件:
- スクロールさせるのは table ではなく外側の入れ物にする。
  table 自体に overflow を掛けても効かない、とコメントで書く。
- 端に影を出して「まだ右がある」ことを伝える。白のグラデ2枚と
  影2枚を重ね、background-attachment の local と scroll の差で
  スクロール位置に応じて出入りさせる。JavaScript は使わない。
- 見出し列(th[scope="row"])を position: sticky で左に固定し、
  box-shadow で本体との境目を出す。
- table には width: 100% ではなく min-width: 100% を指定する。
  100% にすると狭い画面で潰れて読めなくなる、と書く。
- 列見出しに scope="col"、行見出しに scope="row" を必ず付ける。
  これが無いとスクリーンリーダーがセルの対応を推測するしかない、と書く。
- caption で表の内容と時点を書く。
- 数値のセルは右寄せ+font-variant-numeric: tabular-nums にする。
- 外側に tabindex="0" と role="region"、aria-label を付ける。
- 日本語の本文なので line-height は 1.85、letter-spacing は 0 にする。
02

狭い場所で行をカードに畳む

単独で開く ↗

判断は画面幅ではなく置き場所の幅(コンテナクエリ)です。display を block に変えると表の内部構造が失われるので、role を書き足して意味だけ残しています。

HTML
<div class="table-host">
  <table role="table">
    <caption>直近の注文</caption>
    <thead>
      <tr role="row">
        <th scope="col" role="columnheader">注文番号</th>
        <th scope="col" role="columnheader">日付</th>
        <th scope="col" role="columnheader">金額</th>
        <th scope="col" role="columnheader">状態</th>
      </tr>
    </thead>
    <tbody>
      <!-- display を block に変えると表としての内部構造が失われるので、
           role を書いて元の意味を残す。data-label は畳んだときの
           見出しに使う。文字列は thead と同じものにする。 -->
      <tr role="row">
        <th scope="row" role="rowheader">#10482</th>
        <td role="cell" data-label="日付">2026-08-28</td>
        <td role="cell" class="num" data-label="金額">¥12,800</td>
        <td role="cell" data-label="状態"><span class="badge">発送済み</span></td>
      </tr>
      <tr role="row">
        <th scope="row" role="rowheader">#10481</th>
        <td role="cell" data-label="日付">2026-08-27</td>
        <td role="cell" class="num" data-label="金額">¥3,200</td>
        <td role="cell" data-label="状態"><span class="badge">準備中</span></td>
      </tr>
      <tr role="row">
        <th scope="row" role="rowheader">#10480</th>
        <td role="cell" data-label="日付">2026-08-25</td>
        <td role="cell" class="num" data-label="金額">¥48,600</td>
        <td role="cell" data-label="状態"><span class="badge">発送済み</span></td>
      </tr>
      <tr role="row">
        <th scope="row" role="rowheader">#10479</th>
        <td role="cell" data-label="日付">2026-08-21</td>
        <td role="cell" class="num" data-label="金額">¥980</td>
        <td role="cell" data-label="状態"><span class="badge">キャンセル</span></td>
      </tr>
    </tbody>
  </table>
</div>
CSS
.table-host { container-type: inline-size; }

table { border-collapse: collapse; width: 100%; font-size: .88rem; background: #fff; }
caption {
  caption-side: top; text-align: left;
  padding: 0 0 .5rem; font-size: .84rem; line-height: 1.85; color: var(--muted);
}
th, td {
  padding: .6rem .8rem; border-bottom: 1px solid var(--line);
  text-align: left; line-height: 1.8;
}
thead th { font-size: .78rem; color: var(--muted); font-weight: 600; background: #fbfcfe; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.badge {
  display: inline-block; padding: .05rem .5rem; border-radius: 999px;
  font-size: .74rem; line-height: 1.8; background: #eff6ff; color: var(--accent);
}

/* 置き場所が 520px を切ったらカードに畳む。
   画面幅ではなく親の幅で判断するので、この表を
   サイドバーに置いても本文に置いても正しく振る舞う。 */
@container (max-width: 520px) {
  /* thead は隠すが display: none にはしない。
     見出しの文字列は各セルの ::before で使い回すため、
     HTML からは消さずに視覚的にだけ隠す。 */
  thead {
    position: absolute;
    width: 1px; height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* table / tr / td の display を変えると、ブラウザ内部の表としての
     扱いも変わる。行と列の対応を失わないよう、role を明示して
     元の意味を保ったまま見た目だけを変える(HTML 側に role を書く)。 */
  table, tbody, tr, th, td { display: block; }

  tr {
    border: 1px solid var(--line);
    border-radius: 12px;
    padding: .7rem .9rem;
    margin-bottom: .7rem;
  }
  tr:last-child { margin-bottom: 0; }

  th[scope="row"] {
    border-bottom: 1px solid var(--line);
    padding: 0 0 .5rem;
    margin-bottom: .5rem;
    font-size: .98rem;
  }

  td {
    display: flex;
    justify-content: space-between;
    gap: 1rem;
    border-bottom: 0;
    padding: .15rem 0;
  }
  /* 見出しは data 属性から出す。文字列を CSS に直接書くと、
     HTML の見出しを変えたときに必ず食い違う。 */
  td::before {
    content: attr(data-label);
    color: var(--muted);
    font-size: .8rem;
    flex: none;
  }
  td.num { text-align: right; }
}
AIへの指示文
狭い場所ではカードに畳まれる表を作ってください。
要件:
- 判断はメディアクエリではなくコンテナクエリで行う。同じ表を
  サイドバーに置いても本文に置いても正しく振る舞う、と書く。
- 520px を切ったら table / tbody / tr / th / td を display: block にする。
- display を変えると表としての内部構造が失われるので、HTML 側に
  role="table" から role="cell" まで書いて意味を保つ。これが
  この手法の必須条件だ、とコメントで書く。
- thead は display: none にせず、視覚的に隠す(position: absolute と
  clip-path)。見出しの文字列を各セルの表示に使い回すため。
- 畳んだときの項目名は td::before で data-label から出す。
  文字列を CSS に直接書くと HTML と食い違う、と書く。
- 幅をスライダーで変えられるようにして、画面幅を変えずに
  切り替わることを確かめられるようにする。
03

並べ替えと aria-sort

単独で開く ↗

矢印は aria-sort 属性から CSS で描いています。状態をクラスで二重に持たないので、表示と実際の並びがずれません。並べ替えたことは aria-live でも伝えます。

HTML
<table id="tbl">
  <caption>担当者ごとの実績</caption>
  <thead>
    <tr>
      <!-- 並べ替えの状態は aria-sort だけで持つ。値は
           ascending / descending / none の3つ。
           data-key はどの値で並べるかの指定。 -->
      <!-- 表示は name(漢字)、並べ替えは data-sort-by で指定した
           kana(読み)を使う。漢字のまま比べると五十音順にならない。 -->
      <th scope="col" data-key="name" data-sort-by="kana" aria-sort="none">
        <button type="button">担当者</button>
      </th>
      <th scope="col" class="num" data-key="deals" data-type="number" aria-sort="none">
        <button type="button">件数</button>
      </th>
      <th scope="col" class="num" data-key="amount" data-type="number" aria-sort="none">
        <button type="button">金額</button>
      </th>
      <th scope="col" data-key="updated" data-type="date" aria-sort="none">
        <button type="button">最終更新</button>
      </th>
    </tr>
  </thead>
  <tbody id="rows"></tbody>
</table>

<!-- 並べ替えたことを音声でも伝える。画面を見ていない人には
     表が入れ替わったことが分からないため。 -->
<p class="sr-only" id="live" aria-live="polite"
   style="position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)"></p>
CSS
table {
  border-collapse: collapse;
  width: 100%;
  font-size: .88rem;
  background: #fff;
  border: 1px solid var(--line);
  border-radius: 12px;
  overflow: hidden;
}
caption {
  caption-side: top; text-align: left;
  padding: 0 0 .5rem; font-size: .84rem; line-height: 1.85; color: var(--muted);
}
th, td { padding: .6rem .8rem; border-bottom: 1px solid var(--line); text-align: left; line-height: 1.8; }
thead th { background: #fbfcfe; font-size: .78rem; color: var(--muted); font-weight: 600; padding: 0; }
tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }

/* 見出しの中身は button。th 自体に click を付けると
   キーボードで押せず、押せることも伝わらない。 */
thead th button {
  display: flex;
  align-items: center;
  gap: .35rem;
  width: 100%;
  padding: .6rem .8rem;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
  text-align: inherit;
}
thead th.num button { justify-content: flex-end; }
thead th button:hover { color: var(--accent); }

/* 矢印は aria-sort から出す。状態をクラスで二重に持つと必ずずれる。
   ::after なので読み上げには乗らない(読み上げには aria-sort が伝わる)。 */
thead th button::after { content: "↕"; opacity: .35; font-size: .8em; }
thead th[aria-sort="ascending"] button::after { content: "↑"; opacity: 1; color: var(--accent); }
thead th[aria-sort="descending"] button::after { content: "↓"; opacity: 1; color: var(--accent); }
thead th[aria-sort] button { color: var(--accent); }
JavaScript
// 並べ替えの元になるのは「表示用の文字列」ではなく元の値。
// 「¥1,000」を文字列比較すると 200 より小さくなる。
// 日本語の氏名は、漢字のままでは五十音順に並ばない。
// localeCompare は漢字を「読み」ではなく符号位置の照合順で比べるため、
// 青木・石田・上原…(あ→い→う)ではなく別の順序になる。
// 読みは漢字から機械的に求められないので、必ず別の項目として持つ。
// 実務でも「氏名カナ」を入力させるのはこのためで、後付けは効かない。
const DATA = [
  { name: "青木", kana: "あおき", deals: 18, amount: 4820000, updated: "2026-08-28" },
  { name: "石田", kana: "いしだ", deals: 7, amount: 12400000, updated: "2026-07-14" },
  { name: "上原", kana: "うえはら", deals: 24, amount: 3150000, updated: "2026-08-30" },
  { name: "遠藤", kana: "えんどう", deals: 11, amount: 980000, updated: "2026-06-02" },
  { name: "大石", kana: "おおいし", deals: 3, amount: 22500000, updated: "2026-08-19" },
];

const tbl = document.getElementById("tbl");
const rows = document.getElementById("rows");
const live = document.getElementById("live");
const yen = new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" });

let sortKey = null;
let dir = "ascending";

function render() {
  const list = [...DATA];
  if (sortKey) {
    const th = tbl.querySelector(`th[data-key="${sortKey}"]`);
    const type = th.dataset.type ?? "string";
    const sign = dir === "ascending" ? 1 : -1;
    list.sort((a, b) => {
      // 並べ替えに使う値と、画面に出す値は別でよい。
      // 氏名は表示が漢字、比較は読み(kana)で行う。
      const key = th.dataset.sortBy ?? sortKey;
      const x = a[key], y = b[key];
      // かな同士なら localeCompare で正しく五十音順になる。
      // 濁点や長音の扱いも規則が決まっているので、自分で比較しない。
      if (type === "string") return x.localeCompare(y, "ja") * sign;
      if (type === "date") return (new Date(x) - new Date(y)) * sign;
      return (x - y) * sign;
    });
  }

  rows.innerHTML = "";
  for (const r of list) {
    const tr = document.createElement("tr");
    const th = document.createElement("th");
    th.scope = "row";
    th.textContent = r.name;
    const deals = document.createElement("td");
    deals.className = "num";
    deals.textContent = r.deals;
    const amount = document.createElement("td");
    amount.className = "num";
    amount.textContent = yen.format(r.amount);
    const updated = document.createElement("td");
    // 機械が読む値は time の datetime に、人が読む形は本文に。
    const t = document.createElement("time");
    t.dateTime = r.updated;
    t.textContent = r.updated.replaceAll("-", "/");
    updated.append(t);
    tr.append(th, deals, amount, updated);
    rows.append(tr);
  }
}

tbl.querySelectorAll("thead th[data-key]").forEach((th) => {
  th.querySelector("button").addEventListener("click", () => {
    const key = th.dataset.key;
    // 同じ列を押したら向きだけ反転。別の列なら昇順から始める。
    dir = sortKey === key && dir === "ascending" ? "descending" : "ascending";
    sortKey = key;

    // aria-sort は表の中で1つだけ。残したままにすると
    // 「2つの列で同時に並んでいる」という嘘になる。
    tbl.querySelectorAll("thead th[data-key]").forEach((x) =>
      x.setAttribute("aria-sort", x === th ? dir : "none"),
    );

    render();
    live.textContent =
      `${th.textContent.trim()}の${dir === "ascending" ? "昇順" : "降順"}で並べ替えました`;
  });
});

render();
AIへの指示文
見出しを押すと並べ替わる表を作ってください。
要件:
- 並べ替えの状態は aria-sort(ascending / descending / none)だけで持つ。
  矢印はその属性から CSS の ::after で描く。状態をクラスで二重に
  持つと必ずずれる、とコメントで書く。
- aria-sort が付くのは表の中で1列だけ。他は none に戻す。
  残すと「2列で同時に並んでいる」という嘘になる、と書く。
- 見出しの中身は button にする。th 自体に click を付けると
  キーボードで押せず、押せることも伝わらない、と書く。
- 並べ替えの基準は表示用の文字列ではなく元の値にする。
  「¥1,000」を文字列比較すると 200 より小さくなる、と書く。
- 型ごとに比較を分ける。かな文字列は localeCompare(x, "ja") に任せる。
- 日本語の氏名は、漢字のままでは五十音順にならない。localeCompare は
  漢字を読みではなく符号位置の照合順で比べるため。データに読み(kana)を
  別項目として持ち、表示は漢字・比較は読み、と分ける。読みは漢字から
  機械的に求められないので後付けは効かない、という理由をコメントで書く。
- 日付は time 要素の datetime に機械可読な値を入れる。
- 並べ替えたことを aria-live="polite" で伝える。画面を見ていない人には
  表が入れ替わったことが分からない、と書く。
04

数字が比較できる組み方

単独で開く ↗

右寄せ・等幅数字・単位は見出しへ、の3点セット。同じデータを組まずに置いた表を下に並べたので、桁の読み取りやすさの差がそのまま見えます。

HTML
<table>
  <caption>四半期ごとの売上(2026年度)</caption>
  <thead>
    <tr>
      <th scope="col">部門</th>
      <!-- 単位はセルではなく見出しに書く。各セルに「円」を入れると
           桁の位置が右へずれて、縦の比較ができなくなる。 -->
      <th scope="col" class="num">Q1 <span class="unit">(千円)</span></th>
      <th scope="col" class="num">Q2 <span class="unit">(千円)</span></th>
      <th scope="col" class="num">前期比</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">法人営業</th>
      <td class="num">48,200</td>
      <td class="num">51,940</td>
      <td class="delta" data-dir="up">7.8%<span class="sr-only">増加</span></td>
    </tr>
    <tr>
      <th scope="row">個人向け</th>
      <td class="num">9,140</td>
      <td class="num">8,305</td>
      <td class="delta" data-dir="down">9.1%<span class="sr-only">減少</span></td>
    </tr>
    <tr>
      <th scope="row">保守サポート</th>
      <td class="num">122,600</td>
      <td class="num">122,600</td>
      <td class="delta" data-dir="flat">0.0%<span class="sr-only">増減なし</span></td>
    </tr>
    <tr>
      <th scope="row">その他</th>
      <td class="num">705</td>
      <td class="num">1,488</td>
      <td class="delta" data-dir="up">111.1%<span class="sr-only">増加</span></td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <th scope="row">合計</th>
      <td class="num">180,645</td>
      <td class="num">184,333</td>
      <td class="delta" data-dir="up">2.0%<span class="sr-only">増加</span></td>
    </tr>
  </tfoot>
</table>
CSS
table {
  border-collapse: collapse; width: 100%; font-size: .88rem;
  background: #fff; border: 1px solid var(--line); border-radius: 12px; overflow: hidden;
}
caption {
  caption-side: top; text-align: left;
  padding: 0 0 .5rem; font-size: .84rem; line-height: 1.85; color: var(--muted);
}
th, td { padding: .55rem .8rem; border-bottom: 1px solid var(--line); text-align: left; line-height: 1.8; }
thead th { background: #fbfcfe; font-size: .78rem; color: var(--muted); font-weight: 600; }
tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }

/* 数字の列は3点セットで組む。どれか1つでも欠けると比較しづらい。
   1. 右寄せ          … 桁の位置が縦に揃う
   2. tabular-nums    … 文字ごとの幅を等しくして桁をずらさない
   3. 単位は見出しへ  … セルの中に単位があると桁が右へずれる */
.num { text-align: right; font-variant-numeric: tabular-nums; }
thead th .unit { font-weight: 400; opacity: .75; }

/* 増減は色だけで示さない。色覚特性によっては区別が付かないので、
   必ず記号も添える。記号は ::before で出し、
   読み上げ用の語は視覚的に隠したテキストで別に持つ。 */
.delta { font-variant-numeric: tabular-nums; text-align: right; }
.delta[data-dir="up"] { color: var(--up); }
.delta[data-dir="down"] { color: var(--down); }
.delta[data-dir="up"]::before { content: "▲ "; }
.delta[data-dir="down"]::before { content: "▼ "; }
.delta[data-dir="flat"]::before { content: "— "; color: var(--muted); }

.sr-only {
  position: absolute; width: 1px; height: 1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}

/* 合計行は本文と区別する。tfoot は tbody の後ろに書いても
   ブラウザが最後に描くので、HTML 上の位置に依存しない。 */
tfoot th, tfoot td {
  border-top: 2px solid var(--line);
  background: #fbfcfe;
  font-weight: 600;
}
AIへの指示文
数字を比較しやすい表を作ってください。
要件:
- 数値の列は3点セットで組む。右寄せ、font-variant-numeric: tabular-nums、
  単位はセルではなく見出しに書く。それぞれの理由をコメントで書く
  (セルに単位を入れると桁の位置が右へずれて縦に比較できない)。
- 増減は色だけで示さない。::before で ▲ ▼ — の記号を添え、
  読み上げ用の「増加」「減少」は視覚的に隠したテキストで別に持つ。
  赤と緑の区別が付きにくい人は珍しくない、と書く。
- 合計は tfoot に入れ、上罫線と背景で本文と区別する。
- 同じデータを「組んでいない状態」でもう1つ並べ、
  桁の読み取りやすさの差が見えるようにする。
- caption で表の内容と時点を書く。

この技術について

表を div で組み直したくなるのは、たいてい狭い画面で崩れるからです。

しかしそれをやると、行と列の対応が機械には読めなくなります。スクリーンリーダーでは、見出しとの結び付きを失った文字列が順に読み上げられるだけになる。崩れるという理由で構造を捨てる必要はありません。

スクロールするのは table ではない

まず基本から。tableoverflow-x: auto を掛けても効きません。スクロールさせるのは外側の入れ物です。

そして table には width: 100% ではなく min-width: 100% を指定してください。100% にすると、列が多いときに文字が潰れて読めなくなります。中身なりに広がってよい、というのが表の自然な姿です。

overflow-x を掛けるのは table ではなく外側の入れ物。table 自身はスクロールしない table に overflow-x table はみ出したまま。枠の外へ出ていく 外側の div に overflow-x table 枠の中に収まり、横にスクロールできる table には min-width: 100%
スクロールさせるのは外側の入れ物です。table 自体に overflow を掛けても効きません。width: 100% ではなく min-width: 100% にして、中身なりに広がってよい形にします。

「まだ右がある」を CSS だけで伝える

横スクロールで最も多い失敗は、スクロールできること自体が伝わっていないことです。

端に影を出すのが軽い解決策です。白いグラデーションと影を重ね、background-attachmentlocalscroll で使い分けると、スクロール位置に応じて影が出入りします。JavaScript は要りません。

さらに見出し列を position: sticky; left: 0 で貼り付けると、横に流したときに「どの行を見ているか」を見失いません。比較表では必須に近い処置です。

見出し列を左に固定し、右端の影で「まだ続きがある」ことを伝える th[scope=row] フリー スタンダード プロ 左に固定 = どの行かを見失わない 右端の影 = まだ続きがある 影の出し入れは background-attachment の local と scroll の差だけで作れる(JavaScript は不要)
影を消すと、この図はただの切れた表になります。左の固定と右の影は、どちらも「いま見ている場所」を教えるためのもので、片方だけでは足りません。

カード化には role の書き足しが要る

狭いときに行をカードへ畳む手法は広く使われていますが、そのままでは表としての意味が壊れます

display: block にした時点で、ブラウザ内部での「表」の扱いは失われます。scope も行と列の対応も効きません。role="table" から role="row"role="rowheader"role="cell" まで書き足して、意味だけを元のまま保ってください。この書き足しは省略可能な装飾ではなく、この手法の必須条件です。

判断はメディアクエリではなくコンテナクエリで行うのが良い選択です。同じ表を本文にもサイドバーにも置けるようになります。

theaddisplay: none にせず、視覚的にだけ隠してください。畳んだときの項目名は td::beforedata-label から出しますが、content に文字列を直接書くと HTML の見出しを変えたときに必ず食い違います。

display を block にすると表の内部構造が失われるので、role を書き足して意味を保つ 広いとき 注文番号 日付 金額 状態 行と列の対応は table 要素が持っている 畳む 狭いとき(カード化) #10482 日付 2026-08-28 #10481 日付 2026-08-27 display: block で対応が失われる role=table / row / rowheader / cell を書き足して意味だけ元のまま保つ 畳んだときの項目名は td::before で data-label から出す(CSSに文字列を書かない)
右の見た目だけを真似た実装が広く出回っています。role を書いていないものは、読み上げると項目名と値の対応が消えます。カード化を採るなら、ここまでを1組として扱ってください。

数字は3点セットで組む

数値の列は、次の3つが揃って初めて比較できます。

  1. 右寄せ — 桁の位置が縦に揃う
  2. font-variant-numeric: tabular-nums — 多くの書体は「1」が細く、右寄せだけでは桁が揃わない
  3. 単位は見出しへ — セルに「千円」を入れると、桁の位置が右へずれる

3番目は見落とされがちです。48,200千円705千円 を並べると、数字の右端が揃わず、縦に読めなくなります。

右寄せ・等幅数字・単位は見出しへ。3つが揃って初めて桁が縦に揃う 組んでいない Q1 48,200千円 9,140千円 122,600千円 705千円 右端が揃わない 3点セットで組む Q1(千円) 48,200 9,140 122,600 705 桁が揃う ① text-align: right ② font-variant-numeric: tabular-nums ③ 単位は見出しへ ③ を忘れると、セルの中の「千円」の分だけ数字の右端がずれる ② を忘れると、多くの書体で「1」が細く、右寄せだけでは揃わない
3番目が見落とされがちです。48,200千円705千円 を並べると、単位の分だけ数字の右端がずれて縦に読めなくなります。

増減を色だけで示さない

赤字と緑字で増減を示すのは自然に見えますが、赤と緑の区別が付きにくい人は珍しくありません

色に加えて のような記号を添えてください。記号は ::before で出せば HTML を汚しません。そのうえで、読み上げ用の「増加」「減少」という語を視覚的に隠したテキストで別に持たせると、記号が読み上げられない環境でも意味が届きます。

増減を色だけで示すと、色の区別が付きにくい人には伝わらない 色だけで示す 7.8% 9.1% 増えた 減った 色が同じに見える人には区別できない 記号と語を添える ▲ 7.8% ▼ 9.1% +読み上げ用「増加」 +読み上げ用「減少」 色・記号・語の3つで伝える 記号は ::before で出せば HTML を汚さない。語は視覚的に隠したテキストで別に持つ。
左の図を白黒で印刷したところを想像してください。増減がまったく判別できなくなります。この一手で確かめられるので、実装前に必ず通してください。

並べ替えの状態は1か所に持つ

aria-sort の値は ascending / descending / none の3つで、表の中で1列にだけ付けます。他の列を none に戻し忘れると、「2つの列で同時に並んでいる」という嘘の状態になります。

見た目の矢印は、この属性から CSS で描いてください。クラスで二重に持つと、表示と実際の並びがずれます。

並べ替えの基準にするのは、表示用の文字列ではなく元の値です。¥1,000 を文字列として比較すると 200 より小さくなります。

そして日本語では、もう一段の落とし穴があります。漢字の氏名は localeCompare では五十音順になりません。照合の基準になるのは読みではなく符号位置の順序だからです。青木・石田・上原(あ→い→う)と並べたければ、データ側に読みを持つしかありません。

{ name: "青木", kana: "あおき", ... }

表示は name、比較は kana。読みは漢字から機械的に求められないので、あとから付け足すことはできません。実務のフォームが「氏名カナ」を求めるのはこのためです。かな同士になれば、濁点や長音の扱いも含めて localeCompare(x, "ja") が正しく並べます。

aria-sort は表の中で1列だけ。矢印はその属性から描くと状態が1か所に収まる 担当者 件数 金額 ↑ 最終更新 none none ascending none この属性から CSS で矢印を描く aria-sort が付くのは1列だけ。 他を none に戻し忘れると「2列で同時に並んでいる」という嘘になる thead th[aria-sort=ascending] button::after { content: "↑" }
状態をクラスでも持つと、表示と実際の並びが必ずどこかでずれます。並べ替えの基準は表示用の文字列ではなく元の値にし、日本語の氏名は読みを別項目として持つ必要があります。

最後に、並べ替えた直後は aria-live で結果を伝えてください。画面を見ていない人には、表が入れ替わったこと自体が分かりません。

使いどころとつまずきどころ

向いている場面

  • 料金プランの比較表
  • 管理画面の一覧と並べ替え
  • 仕様や項目の比較
  • 検索結果を表形式で見せる画面

つまずきやすい点

  • divで組み直すと行と列の対応が機械に読めなくなる
  • 横スクロールできることが見た目で伝わらない
  • 数字が等幅でないと桁が揃わず比べられない
  • 並べ替えの状態を aria-sort で伝えていない
  • 漢字の氏名は localeCompare では五十音順にならない

よくある質問

表を div で組んではいけませんか。

避けてください。行と列の対応が機械に読めなくなり、スクリーンリーダーでは意味の無い文字列の羅列になります。狭い画面で崩れるという理由で構造を捨てる必要はありません。横スクロール、見出し列の固定、カード化のいずれも table 要素のまま実現できます。

カード化のために display を変えるのは問題ありませんか。

そのままだと問題があります。display を block にすると、ブラウザ内部での「表」としての扱いが失われ、scope や行と列の対応が効かなくなります。role="table" から role="row"、role="cell" まで書き足して意味を明示してください。この書き足しがこの手法の必須条件です。

スクロールできることをどう伝えればよいですか。

端に影を出すのが最も軽い方法です。白のグラデーションと影を重ね、background-attachment の local と scroll の違いを使えば、スクロール位置に応じて影が出入りします。JavaScript は要りません。あわせて外側に tabindex="0" を付け、キーボードでもスクロールできるようにしてください。

font-variant-numeric: tabular-nums は必要ですか。

数字を縦に並べて比較させるなら必要です。多くの書体では「1」が他の数字より細く、桁が縦に揃いません。tabular-nums を指定すると全ての数字の幅が等しくなり、桁の位置が揃います。右寄せだけでは揃わない、というのが見落とされがちな点です。

並べ替えの状態はどう伝えればよいですか。

aria-sort を使ってください。値は ascending / descending / none の3つで、表の中で1列にだけ付けます。見た目の矢印はこの属性から CSS で描くと、状態が1か所に収まってずれません。さらに、並べ替えた直後に aria-live で「◯◯の昇順で並べ替えました」と伝えると、画面を見ていない人にも変化が届きます。

日本語の氏名が五十音順に並びません。

漢字を localeCompare で比べているからです。照合の基準になるのは読みではなく符号位置の順序なので、青木・石田・上原(あ→い→う)のようには並びません。読みは漢字から機械的に求められないため、データ側に「氏名カナ」を別項目として持ち、表示は漢字・比較は読み、と分けてください。実務のフォームでカナ入力を求めるのはこのためで、後から付け足すことはできません。

セルを丸ごとリンクにしたいのですが。

td の中に a を置いてください。td 自体に click を付けると、キーボードで到達できず、新しいタブで開くこともできません。行全体を押せるようにしたい場合も、実体は行内のどこか1つのリンクにして、残りは装飾として扱うのが安全です。

同じ技術を使った完成例を、画面の型ごとにまとめています。作りたい画面が決まっているなら、こちらから探すほうが早いはずです。

ほかの技術