はじめに
日付が縦に数十行、項目が横に十数列ある集計表を、「見出し2段・合計行・先頭2列は固定、残りだけスクロール」にしたくて position: sticky を使いました。1行で済むつもりが、実際には7か所で詰まりました。
AI(Claude Code)と一緒に Chrome 上で検証しながら直したので、症状ごとに「原因 → 対処」の形で残しておきます。
先に結論(チェックリスト)
-
sticky の基準になる「スクロールする祖先」はひとつにまとめたか(
overflow: auto+max-height) -
2段目の見出しの
top、2列目のleftは実測値から決めているか -
colspanのセルに固定用クラスを付けていないか -
border-collapse: collapseのままにしていないか -
角・見出し・固定列の
z-indexと、固定セルの背景色を指定したか - ResizeObserver を表と枠の両方に付けたか
- 非表示タブの表を、表示時に測り直しているか
症状1: 縦方向に固定されない
原因
横はみ出し対策で表を overflow-x: auto の要素で包んでいました。sticky は最寄りの「スクロールする祖先」に貼り付くため、この箱が基準になります。箱自体は縦にスクロールしないので、top: 0 は事実上効きません。overflow-x だけの指定でもこうなる点がわかりにくいところです。
対処
縦横どちらもスクロールする箱を基準にし、高さを区切ります。
.scroll-box {
overflow: auto;
max-height: calc(100vh - 160px);
}
max-height が無いと箱が伸びきって縦スクロールが発生せず、固定の意味がなくなります。
症状2: 見出し2段目が1段目に重なる
原因
両方の段に top: 0 を付けていたため、同じ位置に貼り付いていました。
対処
2段目の top を1段目の高さにします。固定値(top: 28px など)はフォントや拡大率、見出しの折り返しでずれるので、JavaScript で実測して CSS 変数に入れました。
function place(table) {
const head = table.tHead;
const row1 = head.rows[0].getBoundingClientRect().height;
const headAll = head.getBoundingClientRect().height;
const col1 = head.querySelector(".col1").getBoundingClientRect().width;
table.style.setProperty("--head1-h", Math.floor(row1) + "px");
table.style.setProperty("--head-h", Math.floor(headAll) + "px");
table.style.setProperty("--col1-w", Math.floor(col1) + "px");
}
thead th { position: sticky; top: 0; }
thead tr:nth-child(2) th { top: var(--head1-h, 0px); }
Math.floor で切り捨てるのは、小数の高さで固定セル間に1px未満の隙間ができるのを避けるためです(ずれても重なる側に倒れる)。
症状3: 2列目の固定位置がずれる / 表が崩れる
対処
2列目の left には1列目の実測幅を渡します。
.col1 { position: sticky; left: 0; }
.col2 { position: sticky; left: var(--col1-w, 0px); }
結合セルの扱いに注意が必要でした。
| セル | 固定してよいか |
|---|---|
colspan ≥ 2(見出しのグループ名など) |
NG。幅が合わず崩れる |
rowspan="2"(2段ぶんの「期間」など) |
OK。1列目として普通に固定できる |
表によって見出しの構造がばらばらだったため、「先頭から連続する th だけの行」を見出しとみなし、rowspan / colspan を数えて列位置を割り出す処理にしました。thead が無い表にも対応できます。
症状4: スクロールすると固定セルの罫線が消える
原因
border-collapse: collapse では罫線がセルではなく表側で描画されます。sticky でセルが動いても線は元の位置に残り、表と一緒に流れていきます。
対処
separate に切り替えて線をセル自身に持たせ、固定部分の境目は box-shadow で描きます(border を足すと寸法が1px変わり、実測値とずれるため)。
table { border-collapse: separate; border-spacing: 0; }
th, td { border-left: 1px solid #ccc; border-top: 1px solid #ccc; }
thead tr:last-child th { box-shadow: inset 0 -1px #333; }
.col2 { box-shadow: inset -1px 0 #333; }
左と上だけだと右端・最下行の線が欠けるので、そこだけ補います。固定列の影と隣セルの左線が重なって太く見えるときは、隣セルの左線を消します。
症状5: 左上の角が隠れる・下の数字が透ける
対処
縦横固定では、セルの重なり順を 角 > 見出し > 固定列 にします。
.col1, .col2 { z-index: 1; }
thead th { z-index: 2; }
thead .col1, thead .col2 { z-index: 3; }
また、固定セルの背景が透明だと下を流れるセルが透けるので、背景色は必須です(ダークモード対応なら CSS 変数で)。
合計行を見出し直下に固定する場合は、合計行を tbody の先頭に置き、top に見出し全体の高さを入れます。合計行の先頭2列は角と同じ扱いです。
.sum-row td { position: sticky; top: var(--head-h, 0px); z-index: 1; }
.sum-row .col1, .sum-row .col2 { z-index: 3; }
症状6: 横スクロールバーが出ると1行足りない
原因
枠の高さを「見出し+15行」に合わせていましたが、横スクロールバーが出ると内側の高さが減り14行しか見えなくなりました。ResizeObserver を表にしか付けておらず、ウィンドウ幅が変わっても表のサイズは変わらないので再計算されていませんでした。
対処
枠も監視対象にします。
const ro = new ResizeObserver(() => place(table));
ro.observe(table);
ro.observe(table.closest(".scroll-box"));
書き込み → サイズ変化 → 再計算のループは、同じ値になった時点で止まりました。念のため「前回と同じ値なら書き込まない」比較を入れておくと安心です。
症状7: タブの中の表だけ固定位置が0px
原因
display: none の要素はサイズ0として測られるため、ページ読み込み時に非表示タブ内の表を測ると全部0pxになります。
対処
ResizeObserver に加えて、タブ切り替え時にも place() を呼ぶようにしました。
検証方法: スクリーンショットより座標
1px未満の隙間は画像では見落とすので、枠を実際にスクロールさせてから座標を読みました。
const box = document.querySelector(".scroll-box");
box.scrollTop = 250;
box.scrollLeft = 500;
const boxTop = box.getBoundingClientRect().top;
const rows = box.querySelectorAll("thead tr");
console.log(rows[0].getBoundingClientRect().top - boxTop); // 0 であること
console.log(rows[1].getBoundingClientRect().top - boxTop); // 1段目の高さと一致すること
あわせて、固定セルの位置で document.elementFromPoint() を呼び、固定セル自身が返ってくる(隙間から下の行が見えていない)ことも確認しました。これが一番確実でした。
まとめ
今回の詰まりは、ほぼすべて「固定位置を別の要素の大きさから決めている」ことに起因していました。
- 2段目の見出し ← 1段目の高さ
- 2列目 ← 1列目の幅
- 合計行 ← 見出し全体の高さ
- 枠の高さ ← 行の高さ
フォント・拡大・注記・スクロールバー・非表示タブなど「大きさが変わる場面」で測り直しが走るかを確認しておけば、sticky の表はかなり安定します。
元になった作業記録(AI 側の視点で書いたもの): https://kujiragames.com/2026/10/sticky-table-header-columns/