position:stickyが効かない7つの原因と直し方【CSS実例】

position:stickyが効かない7つの原因と直し方【CSS実例】

結論:sticky が効かない原因の大半は祖先要素の overflow

  • position: sticky が効かない原因の大半は、祖先要素の overflow が visible 以外になっていること
  • 横スクロール対策で入れた body { overflow-x: hidden; } はページ内のstickyを全部無効化する。overflow-x: clip に替えるだけで直る
  • 次に多いのが top などのオフセット未指定と、親要素に動く余白がない(親の高さ=要素の高さ)の2つ
  • テーブルの見出し固定が効かないときは border-collapse: collapse が犯人
  • transform を持つ祖先は position: fixed を壊すが、stickyは原則壊さない。ここは誤情報が多いので後述する

以下、実案件で踏んだ順に、診断のしかたと直し方をまとめます。

まず30秒でやる自己診断

原因を勘で探すより、祖先を遡って overflow の計算値を出したほうが速いです。DevToolsのコンソールに以下を貼り、stickyさせたい要素を事前に選択(Elementsパネルでクリック=$0)してから実行します。

(() => {
  let el = $0, out = [];
  while (el && el !== document.documentElement) {
    const s = getComputedStyle(el);
    out.push({
      node: el.tagName.toLowerCase() + (el.className ? '.' + String(el.className).split(' ')[0] : ''),
      overflow: s.overflow + ' / x:' + s.overflowX + ' / y:' + s.overflowY,
      contain: s.contain,
      height: s.height
    });
    el = el.parentElement;
  }
  console.table(out);
})();

出力の overflow 列に hidden auto scroll が1つでもあれば、その要素が「スクロールの親」になっています。stickyはその親の中でしか動きません。親自身がスクロールしないなら、stickyは一生動かない=効いていないように見える、という理屈です。

このスニペットで contain 列も一緒に見ているのは、contain: paint(や contain: content)を持つ祖先も同じようにstickyを閉じ込めるためです。UIライブラリやページビルダー由来のラッパーに入っていることがあります。

原因① 祖先の overflow が visible 以外(最頻)

一番多いのがこれです。しかも自分で書いた覚えがないところに入っています。

/* 横スクロールを止めるつもりで書いた1行が、sticky全滅の原因になる */
body { overflow-x: hidden; }

overflow-x だけを指定しても、CSSの仕様上 overflow-y は auto として扱われます。結果、body がスクロールコンテナ扱いになり、その中のstickyが軒並み止まります。

直し方は clip への置換です。

body { overflow-x: clip; }

clip は「はみ出しを切るがスクロールコンテナは作らない」値で、Chrome 90以降・Firefox 81以降・Safari 16以降で使えます。置換したあとに確認すべき副作用は次の2点だけでした。

  • clip はスクロールできないため、JSで body.scrollLeft を触っている箇所があると動かなくなる(実務ではまず無い)
  • 古い環境向けに横スクロール抑止を残したい場合は @supports (overflow: clip) で二段構えにする
body { overflow-x: hidden; }
@supports (overflow: clip) {
  body { overflow-x: clip; }
}

自作テーマ・子テーマでは .l-wrapper .l-main .l-content あたりのラッパーにも同じ指定が潜んでいます。上のスニペットで祖先を全部洗ってから消すのが確実です。

原因② top / bottom を指定していない

position: sticky は、オフセットを1つも指定しないと static とほぼ同じ挙動になります。「効かない」と言われて見にいくと、これが理由だったケースが体感で2割ほどあります。

/* NG:どこで止まるか指定していない */
.c-toc { position: sticky; }

/* OK */
.c-toc {
  position: sticky;
  top: 16px;
}

固定ヘッダーがあるサイトでは、ヘッダーの高さぶんを足します。高さをCSS変数に逃がしておくと、後述の scroll-margin-top と値を共有できて楽です。

:root { --header-h: 64px; }

.c-toc {
  position: sticky;
  top: calc(var(--header-h) + 16px);
}

原因③ 親要素に「動ける余白」がない

stickyは親要素のボックスの中だけを移動します。親の高さと要素の高さがほぼ同じなら、動く距離がゼロなので、指定は効いていても見た目は何も起きません。

サイドバーで多いパターンです。

/* 親(カラム)が中身と同じ高さしかない=stickyが動けない */
.l-sidebar { }
.c-toc { position: sticky; top: 16px; }

この場合、親(.l-sidebar)ではなく、そのさらに親であるカラムのラッパーが本文と同じ高さになっているかを確認します。2カラムをFlexで組んでいるなら、サイドバー側のカラムは本文と同じ高さに伸びているはずなので、その中で .c-toc を動かします。逆に height: 100% を親に入れて本文と同じ高さにしたつもりが、親の親に高さが無くて 100% が解決できていない、というのもよくある行き止まりです。

原因④ Flex / Grid の子で高さが伸び切っている

Flexアイテムは既定(align-items: stretch)で親の高さいっぱいに伸びます。伸び切ったアイテムは原因③と同じで移動距離がゼロです。

.l-columns { display: flex; gap: 32px; }

/* 子を伸ばさず、上寄せにして移動距離を作る */
.l-columns__side {
  align-self: flex-start;   /* ← これが無いと動かない */
  position: sticky;
  top: calc(var(--header-h) + 16px);
}

Gridでも同じで、align-self: start を入れます。「stickyはFlexの中では使えない」と書かれた記事を見かけますが、正しくは「伸び切っていると動けない」だけです。

原因⑤ テーブルの見出し固定が効かない

や

を固定するとき、次のCSSだと効きません。

/* NG:border-collapse: collapse だと thead の sticky が無視される */
.c-table { border-collapse: collapse; }
.c-table th { position: sticky; top: 0; }

border-collapse: collapse を使うと、セルの罫線がテーブル側に統合される関係で、固定した見出しの罫線が消える・そもそも固定されない、という挙動になります。separate に戻し、罫線は box-shadow で描くのが安定した回避策です。

.c-table {
  border-collapse: separate;
  border-spacing: 0;
  width: 100%;
}
.c-table th {
  position: sticky;
  top: 0;
  z-index: 2;
  background: #fff;                     /* 背景必須:透けると下の行が見える */
  box-shadow: inset 0 -1px 0 #d4d4d8;   /* 罫線の代替 */
}

背景色の指定を忘れると、スクロールした行が見出しの下に透けて見えます。これも「効いていないように見える」典型です。

原因⑥ 重なり順(z-index)で隠れているだけ

stickyは効いているのに、固定ヘッダーやカードの下に潜り込んでいるケースです。stickyは要素に新しい重なり文脈を作らないため、後続要素に負けることがあります。

.c-toc {
  position: sticky;
  top: calc(var(--header-h) + 16px);
  z-index: 10;   /* ヘッダーより下、本文より上に置く */
}
.l-header { z-index: 100; }

まずDevToolsで要素の座標を見て、画面外ではなく「重なりで見えない」だけかを切り分けます。

原因⑦ transform の祖先を疑う前に読むこと

「祖先に transform があるとstickyが壊れる」という説明をよく見ますが、これは position: fixed の話です。transform filter perspective を持つ要素は、fixed の基準(包含ブロック)をビューポートから自分自身に変えてしまうため fixed が壊れます。stickyの基準はあくまで「最も近いスクロール可能な祖先」なので、transform だけでは原則壊れません。

実際に壊れるのは、アニメーション用のラッパーに overflow: hidden や contain: paint が同居しているときです。アコーディオンやモーダル、スライダーの実装で、transform と overflow: hidden はセットで書かれがちなので、犯人が取り違えられているのだと思います。診断は原因①のスニペットに戻ってください。

ついでに直しておくと事故が減る2点

アンカーリンクが固定ヘッダーに隠れる

sticky/fixedヘッダーを入れると、目次から見出しへ飛んだときにヘッダーの下に潜ります。JSでのオフセット計算は不要で、CSS1行で済みます。

:where(h2, h3, h4)[id] {
  scroll-margin-top: calc(var(--header-h) + 16px);
}

モバイルでヘッダー高が変わる

ヘッダーの高さをブレークポイントで変えているなら、CSS変数も一緒に切り替えます。top と scroll-margin-top の両方が追随するので、修正箇所が1つで済みます。

:root { --header-h: 56px; }
@media (min-width: 768px) {
  :root { --header-h: 64px; }
}

よくある質問

Q. position: -webkit-sticky は今も必要ですか。 不要です。現行のSafariを含め主要ブラウザは接頭辞なしで対応しています。古いコードに残っている場合は、position: -webkit-sticky が後ろに来ていると上書き事故の原因になるので削除して構いません。

Q. stickyが「途中で止まる」のはなぜですか。 親要素のボックスの終端に達したためです。stickyは親の外には出ません。最後まで追従させたいなら、親を本文全体のラッパーに広げます。

Q. 目次を追従させるとページ表示速度に影響しますか。 CSSのみのstickyはスクロールイベントを使わないため、JSでの追従実装と比べて描画コストはほぼ増えません。むしろJS実装からstickyへ置き換えるほうが軽くなります。

関連記事

多言語・ベトナム語サイトの制作でお困りではありませんか?

フォント・文字化け・WordPress多言語化・サーバー選定まで、多言語サイト制作の実績を持つ制作会社が一括対応します。お見積り・ご相談は無料です。

多言語サイト制作の詳細を見る →

すぐに相談したい方は お問い合わせフォームへ

関連記事(コーディング実務Tips)

Web制作Tips(コーディング実務)カテゴリの最新記事