HTMLテーブルのデザインは、border-collapse・padding・border の3つを整えた「土台のCSS」を1つ用意し、そこへストライプや角丸などの飾りと、スマホ向けの対応(横スクロール・縦積み・カード化のいずれか)を足していくと、短いコードで見やすく仕上がります。 レスポンシブ対応は「列数」と「行どうしを見比べるかどうか」で方式を選ぶのがコツです。

この記事では、そのまま貼って使えるHTMLとCSSを、デザイン6種類とレスポンシブ3方式、ヘッダー・先頭列の固定、:has() による行ハイライト、アクセシビリティ対応まで順に掲載します。すべてのパターンは Playwright 1.56(Chromium 141)で表示幅 320・375・768・1280px のスクリーンショットと実測値を取り、ページのはみ出しがないことを確認しました。「border-collapse: collapse と sticky を組み合わせると罫線が残らない」「横スクロールの枠の中では画面上部への固定が働かない」といった、つまずきやすい点も実測値つきで説明します。

テーブルCSSの早見表

レスポンシブ3方式の選び方

方式 スマホでの見え方 向いている表 列数の目安 行どうしの比較 追加するHTML
横スクロール 表の形のまま、横にスワイプ 数値の一覧、比較表、料金表、月別データ 5列以上でも可 しやすい 表を囲む div 1つ
縦積み(data-label) 1行が「項目名:値」の縦並びになる 会員情報、注文の明細、仕様一覧 3〜6列 しにくい 各 td に data-label
カード化 1行が1枚のカードになる 記事・備品・タスクなど「1件ずつ読む」一覧 3〜6列 しにくい 各 td に data-label
そのまま(対応なし) 幅に合わせて折り返す 2〜3列で文章中心の表 2〜3列 しやすい なし

行と行を見比べる表は横スクロール、1件ずつ読む表は縦積みかカード化を選ぶと、内容に合った見え方になります。

やりたいこと別のCSS逆引き

やりたいこと 使うCSS 掲載セクション
罫線を1本にまとめる border-collapse: collapse 土台のCSS
1行おきに色を付ける tbody tr:nth-child(even) ストライプ
表の角を丸くする border-collapse: separate + border-spacing: 0 + border-radius + overflow: hidden 角丸
マウスを乗せた行に色を付ける tbody tr:hover ホバー
数値の桁をそろえる text-align: right + font-variant-numeric: tabular-nums 土台のCSS
列幅を均等にする table-layout: fixed 料金表
スマホで横スクロールさせる 囲みの div に overflow-x: auto 横スクロール
ヘッダー行を固定する thead th { position: sticky; top: 0 } sticky
先頭列を固定する tbody th { position: sticky; left: 0 } sticky
チェックした行に色を付ける tr:has(input:checked) :has()
表のタイトルを付ける <caption> アクセシビリティ

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「HTMLテーブルのCSSデザイン|レスポンシブ対応コピペ集」

まず用意する土台のHTMLとCSS

以降のパターンはすべて、次の土台にクラスを1つ足す形で使えます。例は備品の一覧です。

<table class="tbl">
  <caption>備品一覧</caption>
  <thead>
    <tr>
      <th scope="col">備品名</th>
      <th scope="col">カテゴリ</th>
      <th scope="col">保管場所</th>
      <th scope="col" class="num">数量</th>
      <th scope="col">状態</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">ノートPC A-01</th>
      <td>PC</td>
      <td>3階 倉庫</td>
      <td class="num">12</td>
      <td>貸出可</td>
    </tr>
    <tr>
      <th scope="row">モニター 27型</th>
      <td>周辺機器</td>
      <td>2階 会議室</td>
      <td class="num">8</td>
      <td>貸出可</td>
    </tr>
    <tr>
      <th scope="row">ホワイトボード</th>
      <td>什器</td>
      <td>1階 受付</td>
      <td class="num">0</td>
      <td>欠品</td>
    </tr>
  </tbody>
</table>
*, *::before, *::after { box-sizing: border-box; }

.tbl {
  width: 100%;
  border-collapse: collapse;   /* 隣り合うセルの罫線を1本にまとめる */
  font-size: 14px;
  line-height: 1.6;
}
.tbl caption {
  caption-side: top;
  text-align: left;
  font-weight: 700;
  padding: 0 0 8px;
}
.tbl th,
.tbl td {
  padding: 10px 12px;
  text-align: left;
  vertical-align: top;
  border-bottom: 1px solid #d9e2ec;
}
.tbl thead th {
  background: #f0f4f8;
  font-weight: 700;
  white-space: nowrap;         /* 見出しを途中で折り返さない */
}
.tbl .num {
  text-align: right;                    /* 数値は右寄せ */
  font-variant-numeric: tabular-nums;   /* 数字の幅をそろえる */
}

土台のポイントは次のとおりです。

プロパティ 役割 補足
border-collapse: collapse セルの罫線を重ねて1本にする 初期値は separate(セルごとに罫線が分かれ、すき間が空く)
width: 100% 親要素の幅いっぱいに広げる 外すと内容の幅に合わせて縮む
padding: 10px 12px セルの内側の余白 上下 8〜12px、左右 12〜16px が読みやすい目安
text-align: left th の中央寄せを左寄せに変える th の初期値は中央寄せ・太字
vertical-align: top 複数行のセルを上にそろえる 初期値は中央(middle)
tabular-nums 数字を等幅にして桁をそろえる 対応フォントで有効

th は見出しセル、td はデータセルです。行の先頭にある「備品名」のように、その行が何のデータかを示すセルは th scope="row" にしておくと、後述の先頭列固定やアクセシビリティ対応でそのまま生きてきます。

デザインのコピペ集

土台の .tbl に、次のクラスを追加して使います(例:<table class="tbl tbl--stripe">)。複数を重ねることもできます。

ストライプ(1行おきに背景色)

.tbl--stripe tbody tr:nth-child(even) {
  background: #f7fafc;
}

行数が多い表で、横方向に目を動かしやすくなります。even を odd にすると奇数行に色が付きます。thead と tbody を分けておくと、見出し行を数えに含めずに済みます。

罫線(格子)

.tbl--grid th,
.tbl--grid td {
  border: 1px solid #bcccdc;
}

縦の線も入れた格子状の表です。border-collapse: collapse のおかげで、隣り合うセルの線が二重にならず1本にまとまります。

罫線のスタイル CSS 印象
横線のみ(土台) border-bottom だけ すっきり。データ量が多い一覧向き
格子 border を4辺 区切りが明確。入力フォーム風の表向き
外枠のみ table に border、セルは線なし 軽い印象。行数が少ない表向き
見出しの下だけ太線 thead th { border-bottom: 2px solid } 見出しとデータの区切りを強調

ホバー(マウスを乗せた行を強調)

.tbl--hover tbody tr:hover {
  background: #e6f6ff;
}

マウス操作ができる画面で、いま見ている行を見失いにくくなります。タッチ端末ではホバーが働かないため、ストライプと併用すると画面を問わず読みやすさを保てます。

角丸

.tbl--round {
  border-collapse: separate;   /* collapse のままだと角が丸くならない */
  border-spacing: 0;           /* セル間のすき間をなくす */
  border: 1px solid #bcccdc;
  border-radius: 12px;
  overflow: hidden;            /* 見出しの背景色を角丸に沿って切り取る */
}
.tbl--round tbody tr:last-child > * {
  border-bottom: 0;            /* 最終行の線が外枠と重ならないようにする */
}

border-collapse: collapse の表に border-radius を指定しても、角は丸くなりません。 Chromium 141 で両方のスクリーンショットを取って確認したところ、collapse の表は getComputedStyle の値こそ 12px になっているものの、描画は四角のままでした。separate と border-spacing: 0 の組み合わせに変えると、見た目は collapse とほぼ同じまま角を丸くできます。

指定 computed の border-radius 実際の描画
border-collapse: collapse + border-radius: 12px 12px 角は四角のまま
border-collapse: separate + border-spacing: 0 + border-radius: 12px + overflow: hidden 12px 角が丸くなる

比較表(おすすめ列を強調)

<div class="table-scroll" role="region" aria-labelledby="cap-cmp" tabindex="0">
  <table class="tbl tbl--compare">
    <caption id="cap-cmp">3つの方式の比較</caption>
    <thead>
      <tr>
        <td></td>
        <th scope="col">方式A</th>
        <th scope="col" class="is-pick">方式B</th>
        <th scope="col">方式C</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">導入のしやすさ</th>
        <td>◎<span class="sr-only">とても良い</span></td>
        <td class="is-pick">○<span class="sr-only">良い</span></td>
        <td>△<span class="sr-only">やや手間</span></td>
      </tr>
      <tr>
        <th scope="row">拡張性</th>
        <td>△<span class="sr-only">やや手間</span></td>
        <td class="is-pick">◎<span class="sr-only">とても良い</span></td>
        <td>○<span class="sr-only">良い</span></td>
      </tr>
      <tr>
        <th scope="row">学習コスト</th>
        <td>低</td>
        <td class="is-pick">中</td>
        <td>高</td>
      </tr>
    </tbody>
  </table>
</div>
.tbl--compare { min-width: 480px; }
.tbl--compare th,
.tbl--compare td {
  text-align: center;
  border: 1px solid #d9e2ec;
}
.tbl--compare tbody th {
  text-align: left;
  background: #f7fafc;
}
/* 強調したい列 */
.tbl--compare .is-pick {
  background: #fffbea;
  border-left: 2px solid #f0b429;
  border-right: 2px solid #f0b429;
}
.tbl--compare thead .is-pick {
  background: #f0b429;
  border-top: 2px solid #f0b429;
}
.tbl--compare tbody tr:last-child .is-pick {
  border-bottom: 2px solid #f0b429;
}

/* 画面には出さず、読み上げにだけ伝えるテキスト */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

強調したい列のセルに is-pick を付けると、その列だけ背景色と太い枠で囲まれます。◎・○・△ の記号は、読み上げソフトでは意図どおりに伝わらないことがあるため、.sr-only で言葉を添えています。 囲みの .table-scroll は次の章の横スクロール用の枠です。

料金表

<div class="table-scroll" role="region" aria-labelledby="cap-price" tabindex="0">
  <table class="tbl tbl--price">
    <caption id="cap-price">プラン別の機能と料金</caption>
    <thead>
      <tr>
        <td></td>
        <th scope="col">ライト<span class="price">0<small>円/月</small></span></th>
        <th scope="col">
          <span class="badge">おすすめ</span><br>
          スタンダード<span class="price">980<small>円/月</small></span>
        </th>
        <th scope="col">チーム<span class="price">2,980<small>円/月</small></span></th>
      </tr>
    </thead>
    <tbody>
      <tr><th scope="row">登録できるタスク数</th><td>50</td><td>無制限</td><td>無制限</td></tr>
      <tr><th scope="row">メンバー数</th><td>1</td><td>5</td><td>30</td></tr>
      <tr>
        <th scope="row">ファイル添付</th>
        <td>—<span class="sr-only">対象外</span></td>
        <td>✓<span class="sr-only">対応</span></td>
        <td>✓<span class="sr-only">対応</span></td>
      </tr>
    </tbody>
  </table>
</div>
.tbl--price {
  min-width: 520px;
  table-layout: fixed;         /* 列幅を内容に左右されず均等にする */
}
.tbl--price th,
.tbl--price td {
  text-align: center;
  border: 1px solid #d9e2ec;
}
.tbl--price thead th { white-space: normal; }
.tbl--price tbody th { text-align: left; width: 34%; }
.tbl--price .price {
  display: block;
  font-size: 22px;
  font-weight: 700;
}
.tbl--price .price small { font-size: 12px; font-weight: 400; }
.badge {
  display: inline-block;
  font-size: 11px;
  background: #2680c2;
  color: #fff;
  border-radius: 999px;
  padding: 0 8px;
}

table-layout: fixed を指定すると、プラン名の長さに関係なく各列が同じ幅になります。 初期値の auto はセルの内容量に応じて列幅が決まるため、文字数の多い列だけが広がります。料金表のように列を対等に見せたい表では fixed が向いています。

レスポンシブ対応①:横スクロール

表の形を保ったまま、スマホでは横にスワイプして見る方式です。表を div で囲み、その div に overflow-x: auto を指定します。

<div class="table-scroll" role="region" aria-labelledby="cap-scroll" tabindex="0">
  <table class="tbl tbl--stripe">
    <caption id="cap-scroll">備品一覧</caption>
    <!-- thead / tbody は土台と同じ -->
  </table>
</div>
.table-scroll {
  position: relative;          /* 中の .sr-only がページ幅を広げないようにする */
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}
.table-scroll > .tbl {
  min-width: 640px;            /* これより狭い画面では横スクロールになる */
}
.table-scroll:focus-visible {
  outline: 2px solid #2680c2;
  outline-offset: 2px;
}

囲みの div が必要な理由(実測)

min-width: 640px の表を、囲みなしで置いた場合と囲んだ場合を、表示幅 375px で測りました。

配置 ページ全体の scrollWidth 表の幅 結果
囲みなし(table を直接置く) 656px 640px ページ全体が横にスクロールする
.table-scroll で囲む 375px 640px(枠の表示幅は 343px) 表の枠の中だけが横にスクロールする

囲みがないと、表の幅に引っぱられてページ全体が横に動くようになり、他の文章まで左右にずれて見えます。table 要素そのものに overflow-x: auto を書いても、表は内容に合わせて広がる性質があるためスクロール枠にはなりません。 囲みの div を1つ足すのが確実です。

表示幅ごとの実測値は次のとおりです(.table-scroll の中の表)。

表示幅 枠の表示幅 枠の中身の幅 横スクロール
320px 288px 640px あり
375px 343px 640px あり
768px 736px 736px なし
1280px 1248px 1248px なし

position: relative を付ける理由(実測)

比較表や料金表で使った .sr-only(読み上げ専用テキスト)は position: absolute です。囲みの枠に position の指定がないと、この要素が枠の外側を基準に配置され、枠からはみ出した位置にある分だけページ全体の幅が広がりました。

.table-scroll の指定 表示幅 375px でのページの scrollWidth
overflow-x: auto のみ 477px(ページが横に動く)
position: relative を追加 375px(はみ出しなし)

横スクロールの枠の中に position: absolute の要素を入れるときは、枠に position: relative を添えておくと、枠の中に収まります。

キーボードでもスクロールできるようにする

tabindex="0" を付けると、Tab キーで枠にフォーカスが移り、矢印キーで横スクロールできます。実際に Tab キーを1回押して、最初に .table-scroll にフォーカスが移ることを確認しました。role="region" と aria-labelledby は、読み上げソフトに「ここは『備品一覧』という名前の領域です」と伝えるための指定です。

レスポンシブ対応②:縦積み(data-label)

スマホでは1行分のデータを「項目名:値」の形で縦に並べる方式です。見出し行は画面から隠し、各セルの data-label 属性に書いた項目名を ::before で表示します。

<table class="tbl tbl--stack">
  <caption>備品一覧</caption>
  <thead>
    <tr>
      <th scope="col">備品名</th>
      <th scope="col">カテゴリ</th>
      <th scope="col">保管場所</th>
      <th scope="col">数量</th>
      <th scope="col">状態</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">ノートPC A-01</th>
      <td data-label="カテゴリ">PC</td>
      <td data-label="保管場所">3階 倉庫</td>
      <td data-label="数量">12</td>
      <td data-label="状態">貸出可</td>
    </tr>
    <tr>
      <th scope="row">モニター 27型</th>
      <td data-label="カテゴリ">周辺機器</td>
      <td data-label="保管場所">2階 会議室</td>
      <td data-label="数量">8</td>
      <td data-label="状態">貸出可</td>
    </tr>
  </tbody>
</table>
@media (max-width: 600px) {
  /* 見出し行は画面から隠す(読み上げには残す) */
  .tbl--stack thead {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .tbl--stack tr {
    display: block;
    border-bottom: 2px solid #bcccdc;
    padding: 8px 0;
  }
  .tbl--stack th,
  .tbl--stack td {
    display: flex;
    justify-content: space-between;
    gap: 16px;
    border-bottom: 0;
    padding: 4px 0;
    text-align: right;
  }
  /* data-label の内容を項目名として表示 */
  .tbl--stack td::before {
    content: attr(data-label);
    font-weight: 700;
    text-align: left;
    flex-shrink: 0;
  }
  .tbl--stack tbody th {
    font-size: 16px;
    justify-content: flex-start;
  }
}

表示幅ごとの確認結果です。

表示幅 td の display td::before の内容 表の幅 見出し行の高さ
375px flex "カテゴリ"(data-label の値) 343px 1px(画面上は見えない)
768px table-cell none 736px 43px
1280px table-cell none 1248px 43px

375px のスクリーンショットでは、行ごとに「ノートPC A-01」という見出しの下へ「カテゴリ PC」「保管場所 3階 倉庫」「数量 12」「状態 貸出可」が左右に分かれて並び、行の境目に区切り線が入る表示になりました。

見出し行を display: none ではなく「1px に切り詰める」方法で隠しているのは、読み上げソフトに列見出しの情報を残すためです(詳しくは後述のアクセシビリティの章)。

つまずきやすい点 起きること 対応
data-label の書き忘れ そのセルだけ項目名が出ない すべての td に付ける。テンプレートで生成すると漏れにくい
属性名の不一致(label と data-label) attr() が空文字になる HTMLとCSSの属性名をそろえる。独自の属性は data- で始める
値が長いセル 項目名と値が詰まる gap を設ける、または td を display: block にして上下に並べる
見出し行を display: none で隠す 読み上げから列見出しが消える 1px に切り詰める方法で隠す

レスポンシブ対応③:カード化

1行を1枚のカードとして見せる方式です。HTMLは縦積みと同じで、クラスを tbl--card に変えるだけで切り替えられます。

@media (max-width: 600px) {
  .tbl--card thead {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .tbl--card,
  .tbl--card tbody,
  .tbl--card caption { display: block; }

  /* 1行 = 1枚のカード。中身は2列のグリッド */
  .tbl--card tr {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 4px 16px;
    border: 1px solid #bcccdc;
    border-radius: 12px;
    padding: 12px 16px;
    margin-bottom: 12px;
    box-shadow: 0 1px 3px rgb(0 0 0 / .08);
  }
  .tbl--card th,
  .tbl--card td {
    display: block;
    border: 0;
    padding: 0;
  }
  /* カードの見出し(行見出し)は2列分の幅を使う */
  .tbl--card tbody th {
    grid-column: 1 / -1;
    font-size: 16px;
    border-bottom: 1px solid #d9e2ec;
    padding-bottom: 6px;
    margin-bottom: 4px;
  }
  /* 項目名は小さく上に表示 */
  .tbl--card td::before {
    content: attr(data-label);
    display: block;
    font-size: 12px;
    color: #627d98;
  }
}
表示幅 tr の display 1行(カード)の幅 1行の高さ
375px grid 343px 154px
768px table-row 736px 43px
1280px table-row 1248px 43px

375px のスクリーンショットでは、角丸の枠の中に「ノートPC A-01」が見出しとして大きく表示され、その下に「カテゴリ/保管場所」「数量/状態」が2列×2段で並ぶカードになりました。縦積みより1件あたりの高さを抑えられるため、件数が多い一覧に向いています。grid-template-columns を 1fr にすれば1列、repeat(3, 1fr) にすれば3列のカードになります。

3方式の比較

観点 横スクロール 縦積み カード化
HTMLの追加 囲みの div 各 td に data-label 各 td に data-label
CSSの量 少ない 中くらい やや多い
1画面に入る件数 多い 少ない 中くらい
列の見比べ しやすい しにくい しにくい
1件の読みやすさ 横に動かす必要がある 読みやすい 読みやすい
セル結合(rowspan・colspan)のある表 そのまま使える 崩れやすい 崩れやすい
列の追加・変更 CSSの変更なし data-label も合わせて更新 data-label も合わせて更新

セル結合がある表や、列数が多い数値の表は横スクロールが安定します。 迷ったときは横スクロールから始め、1件ずつ読む性質の一覧だけ縦積みやカード化に切り替えると、手間と見やすさの釣り合いが取りやすくなります。

position: sticky でヘッダーと先頭列を固定する

行数や列数が多い表では、スクロールしても見出しが見えていると読みやすくなります。position: sticky を見出しセルに指定します。

<div class="sticky-box" role="region" aria-label="月別の在庫数" tabindex="0">
  <table class="tbl tbl--sticky">
    <thead>
      <tr>
        <th scope="col">備品名</th>
        <th scope="col">4月</th>
        <th scope="col">5月</th>
        <!-- …11月まで -->
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">備品 01</th>
        <td class="num">0</td>
        <td class="num">3</td>
        <!-- … -->
      </tr>
      <!-- …14行 -->
    </tbody>
  </table>
</div>
/* スクロールする枠:高さと overflow を指定する */
.sticky-box {
  max-height: 240px;
  overflow: auto;
  border: 1px solid #bcccdc;
}
.tbl--sticky {
  border-collapse: separate;   /* sticky と罫線を両立させる */
  border-spacing: 0;
  min-width: 760px;
}
.tbl--sticky th,
.tbl--sticky td {
  border-bottom: 1px solid #d9e2ec;
  border-right: 1px solid #d9e2ec;
  background: #fff;            /* 背景がないと下の文字が透けて見える */
  white-space: nowrap;
}
/* ヘッダー行を上に固定 */
.tbl--sticky thead th {
  position: sticky;
  top: 0;
  z-index: 2;
  background: #f0f4f8;
}
/* 先頭列を左に固定 */
.tbl--sticky tbody th {
  position: sticky;
  left: 0;
  z-index: 1;
}
/* 左上の角のセルは上にも左にも固定し、いちばん手前に */
.tbl--sticky thead th:first-child {
  left: 0;
  z-index: 3;
}

枠の中を縦に 150px、横に 200px スクロールして、見出しセルの位置を測りました(表示幅 375px)。

セル スクロール前(枠からの位置) スクロール後(枠からの位置) 結果
ヘッダー行(2列目の見出し)の上端 0px 0px 上に固定されている
先頭列(9行目)の左端 0px 0px 左に固定されている
左上の角のセル 上 0px・左 0px 上 0px・左 0px 上にも左にも固定されている
設定の要点 理由
thead や tr ではなく th セルに sticky を指定 セルに指定する方法がブラウザーを問わず安定して働く
固定するセルに background を指定 背景が透明だと、下を流れるセルの文字が重なって見える
z-index を「角 3 > ヘッダー 2 > 先頭列 1」の順に 重なったときにどれを手前に出すかを決める
左上の角のセルに top: 0 と left: 0 の両方 縦横どちらにスクロールしても残る

border-collapse: collapse と sticky の相性

border-collapse: collapse の表で sticky を使うと、固定されたセルに罫線が付いてこないことがあります。 collapse では罫線が個々のセルではなく表全体の格子として描かれるため、セルだけが固定位置に移動しても線は元の位置でスクロールしていくからです。

太い赤の罫線(2px)を付けた表を2種類用意し、同じ量だけスクロールしてスクリーンショットを比べました(Chromium 141)。

指定 スクロール後のヘッダー行 スクロール後の先頭列
border-collapse: collapse + 各セルに border 固定された見出しセルの下の線が途切れ、下の行の内容と直接接して見える 固定された先頭列の右の線と行の線が消え、背景だけが残る
border-collapse: separate + border-spacing: 0 + 各セルに border-right と border-bottom 見出しセルの線が付いたまま固定される 先頭列の線が付いたまま固定される

separate に変えるとセル1つずつが自分の罫線を持つので、固定しても線が一緒に残ります。このとき4辺すべてに border を付けると隣のセルと線が二重になるため、次のように「右と下だけ」に線を引き、表の左端と上端だけ補うと、collapse と同じ見た目になります。

.tbl--sticky-line {
  border-collapse: separate;
  border-spacing: 0;
}
.tbl--sticky-line th,
.tbl--sticky-line td {
  border-right: 1px solid #bcccdc;
  border-bottom: 1px solid #bcccdc;
}
.tbl--sticky-line thead th { border-top: 1px solid #bcccdc; }
.tbl--sticky-line tr > :first-child { border-left: 1px solid #bcccdc; }

横スクロールの枠の中では「画面上部への固定」が働かない

もう1つ、よく出会う現象があります。overflow-x: auto の枠で囲んだ表は、ページを縦にスクロールしてもヘッダーが画面上部に固定されません。

thead th { position: sticky; top: 0 } を指定した30行の表を2つ用意し、ページを表の先頭から 300px スクロールして、見出しセルの位置を測りました。

配置 表の上端(画面からの位置) 見出しセルの上端 結果
囲みなし -300px 0px 画面上部に固定された
overflow-x: auto の枠の中 -300px -300px 表と一緒に画面外へ流れた

原因は、sticky の基準が「いちばん近いスクロール枠」になることです。さらに、overflow-x だけを auto にしても、overflow-y も自動的に auto として扱われます(実際に getComputedStyle で確認すると x・y ともに auto でした)。枠自体は縦にスクロールしないので、見出しは枠の上端に固定されたまま、枠ごとページの外へ流れていきます。

やりたいこと 構成
ページのスクロールに合わせてヘッダーを画面上部に固定したい 横スクロールの枠で囲まない(列数を絞る、または縦積み・カード化と組み合わせる)
横スクロールとヘッダー固定を両立したい 枠に max-height と overflow: auto を指定し、枠の中で縦横にスクロールさせる(前掲の .sticky-box)
サイト上部に固定メニューがある top: 0 をメニューの高さ分(例:top: 64px)に変える

:has() で行や列をハイライトする

:has() は「中に○○を持つ要素」を選ぶセレクターで、現行の主要ブラウザーすべてで使えます。JavaScript なしで、表の中の状態に応じた強調ができます。

<table class="tbl tbl--has">
  <thead>
    <tr>
      <th scope="col"><span class="sr-only">選択</span></th>
      <th scope="col">備品名</th>
      <th scope="col">数量</th>
      <th scope="col">状態</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><input type="checkbox" aria-label="ノートPC A-01 を選択"></td>
      <th scope="row">ノートPC A-01</th>
      <td>12</td>
      <td data-status="貸出可">貸出可</td>
    </tr>
    <tr>
      <td><input type="checkbox" aria-label="モニター 27型 を選択"></td>
      <th scope="row">モニター 27型</th>
      <td>8</td>
      <td data-status="貸出可">貸出可</td>
    </tr>
    <tr>
      <td><input type="checkbox" aria-label="ホワイトボード を選択"></td>
      <th scope="row">ホワイトボード</th>
      <td>0</td>
      <td data-status="欠品">欠品</td>
    </tr>
  </tbody>
</table>
/* 1) チェックを入れた行に色を付ける */
.tbl--has tbody tr:has(input:checked) {
  background: #fff3c4;
}
/* 2) 特定の状態のセルを含む行の文字色を変える */
.tbl--has tbody tr:has(td[data-status="欠品"]) {
  color: #ba2525;
}
/* 3) 3列目のセルにマウスを乗せたら、3列目全体に色を付ける */
.tbl--has:has(tbody td:nth-child(3):hover) :is(th, td):nth-child(3) {
  background: #e6f6ff;
}
/* 4) マウスを乗せた行以外を薄くする */
.tbl--has:has(tbody tr:hover) tbody tr:not(:hover) {
  opacity: .55;
}

Playwright で操作し、getComputedStyle の値を確認しました。

操作 確認した要素 結果
(操作前) 2行目の背景色 rgba(0, 0, 0, 0)(透明)
2行目のチェックボックスをオン 2行目の背景色 rgb(255, 243, 196) に変化(1行目は透明のまま)
(操作なし) 3行目(欠品)の文字色 rgb(186, 37, 37)(1行目は通常の色)
1行目の3列目にマウスを乗せる 見出しの3列目・3行目の3列目の背景色 どちらも rgb(230, 246, 255)
同上 行の opacity マウスを乗せた1行目は 1、3行目は 0.55

列のハイライトは、これまで JavaScript か colgroup の工夫が必要だった表現です。 :has() を使うと「表の中のどこかの3列目がホバーされている」という条件を表全体に対して書けるので、CSSだけで実現できます。列ごとに1ルール必要になるため、列数が多い場合は Sass などのループで生成すると手間が減ります。

アクセシビリティ:caption・scope・aria

表は「縦と横の関係」で意味が決まるため、見た目だけでなく構造を正しく伝えることが大切です。HTML側のひと手間で、読み上げソフトを使う人にも内容が届きやすくなります。

指定 書き方 役割
表のタイトル <caption>備品一覧</caption> 表が何の一覧かを伝える。table の最初の子要素として書く
列見出し <th scope="col"> この見出しが「列」に対するものだと伝える
行見出し <th scope="row"> この見出しが「行」に対するものだと伝える
見出しとデータの区別 見出しは th、データは td td を太字にするだけでは見出しとして伝わらない
行のグループ thead・tbody・tfoot 見出し行・本体・合計行を区別する
横スクロール枠 role="region" + aria-labelledby + tabindex="0" 枠に名前を付け、キーボードで操作できるようにする
記号だけのセル ◎<span class="sr-only">とても良い</span> 記号の意味を言葉で補う
空の見出しセル 左上の角は <td></td> 見出しではないセルを th にしない

縦積み・カード化でも表の構造が保たれるか(確認結果)

縦積みやカード化では tr や td の display を block・flex・grid に変えます。このとき表としての構造が読み上げ側に残るかどうかを、Playwright のアクセシビリティスナップショット(ariaSnapshot())で確認しました。表示幅 375px・Chromium 141 での、縦積みの表の結果です。

- table "備品一覧(縦積み)":
  - caption: 備品一覧(縦積み)
  - rowgroup:
    - row "備品名 カテゴリ 保管場所 数量 状態":
      - columnheader "備品名"
      - columnheader "カテゴリ"
      - columnheader "保管場所"
      - columnheader "数量"
      - columnheader "状態"
  - rowgroup:
    - row "ノートPC A-01 カテゴリ PC 保管場所 3階 倉庫 数量 12 状態 貸出可":
      - rowheader "ノートPC A-01"
      - cell "カテゴリ PC"
      - cell "保管場所 3階 倉庫"
      - cell "数量 12"
      - cell "状態 貸出可"

読み取れることは3つあります。

  • caption が表の名前(table "備品一覧(縦積み)")として使われている。
  • 見出し行は画面上 1px に切り詰めていても、columnheader として残っている。 display: none で隠した場合はこの部分がなくなります。
  • ::before で表示した data-label の文字も、セルの内容(cell "カテゴリ PC")として含まれている。

カード化の表(table を display: block、tr を display: grid に変更)でも、同じく table・row・rowheader・cell の構造が保たれていました。これは Chromium 141 での結果です。ブラウザーや読み上げソフトの組み合わせによって扱いが異なる可能性があるため、対象の環境でも確認しておくと確実です。構造を明示したい場合は、table に role="table"、tr に role="row"、td に role="cell"、th に role="columnheader" または role="rowheader" を添える方法があります。

よくあるつまずきと対処

症状 原因 対処
スマホでページ全体が横に動く 表が画面幅より広い 表を overflow-x: auto の div で囲む
罫線が二重になる・セルの間にすき間がある border-collapse が初期値の separate border-collapse: collapse、または border-spacing: 0
border-radius を指定しても角が丸くならない border-collapse: collapse separate + border-spacing: 0 + overflow: hidden
sticky で固定したセルの下が透けて見える 固定セルの背景が透明 固定するセルに background を指定
sticky で固定すると罫線が消える border-collapse: collapse separate + border-spacing: 0 にして border-right・border-bottom で線を引く
ヘッダーが画面上部に固定されない overflow を持つ枠の中にある 枠に高さを与えて枠内スクロールにする、または枠を外す
列幅が内容によってばらつく table-layout が初期値の auto table-layout: fixed と width を指定
td に width を指定しても効かない 内容の幅や他の行の指定が優先されている table-layout: fixed にして1行目のセル(または col)に幅を指定
長い英数字やURLで表が広がる 折り返す位置がない overflow-wrap: anywhere をセルに指定
縦積みで項目名が出ない data-label の書き忘れ・属性名の不一致 すべての td に data-label を付ける
横スクロール枠を入れてもページが横に動く 枠の中の position: absolute の要素がはみ出している 枠に position: relative を指定

よくある質問

Q. レスポンシブ対応は、どの方式から試すとよいですか? A. 横スクロールから始めるのが手軽です。HTMLは div で囲むだけ、CSSも数行で済み、セル結合のある表にもそのまま使えます。そのうえで、会員情報や注文明細のように1件ずつ読む表だけ、縦積みやカード化に切り替えると無理がありません。

Q. border-collapse は collapse と separate のどちらを使えばよいですか? A. 通常の表は collapse が簡単です。角丸にしたい表と、position: sticky で見出しを固定する表は、separate + border-spacing: 0 を選ぶと罫線が意図どおりに描かれます。見た目は collapse とほぼ同じにできます。

Q. 縦積みで見出し行を display: none にしてもよいですか? A. 画面上は同じ見た目になりますが、読み上げソフトから列見出しが参照できなくなります。この記事のように幅と高さを 1px に切り詰めて clip-path で隠す方法にすると、画面には出さずに見出しの情報を残せます。

Q. テーブルをレイアウト目的(段組み)に使ってもよいですか? A. table は「行と列で意味が決まるデータ」を表すための要素です。段組みやカードの配置には CSS の Grid や Flexbox が適しています。データの表には table、配置には Grid/Flexbox と使い分けると、HTMLの意味と見た目の両方が整います。

まとめ

  • 土台は border-collapse: collapse・padding・border-bottom の3点。 ここへクラスを足してストライプ・格子・ホバー・角丸などを重ねる。
  • スマホ対応は、見比べる表なら横スクロール、1件ずつ読む表なら縦積みかカード化。 横スクロールは表を overflow-x: auto の div で囲む。
  • 縦積み・カード化は data-label と ::before で項目名を表示し、見出し行は 1px に切り詰めて隠すと読み上げにも情報が残る。
  • ヘッダーと先頭列の固定は、th セルに position: sticky と background を指定し、z-index で重なり順を決める。
  • 角丸と sticky は border-collapse: separate + border-spacing: 0 にすると罫線が意図どおりに描かれる。
  • overflow を持つ枠の中では、sticky の基準がその枠になる。 画面上部に固定したいときは枠内スクロールにするか枠を外す。
  • caption・scope・thead/tbody を書くと、読み上げソフトにも表の構造が伝わる。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「HTMLテーブルのCSSデザイン|レスポンシブ対応コピペ集」

株式会社RJCでは、HTMLテーブルのCSSデザインやレスポンシブ対応の工夫を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?