position:fixedが効かない・一緒にスクロールしてしまう原因と直し方|親のtransform/filterが包含ブロックを奪う仕組みと4つの逃がし方

position:fixedが効かない・一緒にスクロールしてしまう原因と直し方|親のtransform/filterが包含ブロックを奪う仕組みと4つの逃がし方

結論:fixedが効かない原因の大半は自分のCSSではなく、祖先要素に付いた transform・filter・will-change。まず犯人を特定してから直す

  • 仕組み=祖先に transform / filter / backdrop-filter / perspective / contain / will-change のいずれかが付くと、position:fixed の基準がビューポートからその祖先へ移る。fixedは書いたとおり効いていて、基準だけがすり替わっている
  • 見分け方=固定したい要素の祖先を上へ辿り、computedStyleでこの6つを見るだけ。コンソールに貼れる十数行のスニペットで一発で出る
  • 直し方の第一選択=fixed要素をbody直下へ出す。ドロワー・モーダル・追従バナーはレイアウトの階層に置かない設計にする
  • 祖先を外せない場合=will-change は常時付けずアニメーション中だけ付けて終わったら外す。ガラス表現の backdrop-filter はぼかし専用の子要素へ分ける
  • 最短の逃げ道=モーダル用途なら dialog要素のshowModal()かPopover API。トップレイヤーに出るので祖先の影響を受けない
  • よく同時に起きる事故=transformはスタッキングコンテキストも作るので、z-indexも同時に効かなくなる。両方出たらほぼこれが原因

position: fixed と top: 0 を書いた。なのに追従ヘッダーがスクロールと一緒に流れていく。ドロワーメニューに inset: 0 を指定したのに画面いっぱいに広がらず、親要素の幅の中だけで開く。開発者ツールで見てもCSSは効いていて、打ち消している宣言も無い——。

これはCSSの書き間違いではありません。祖先要素のどこかに付いた1行が、fixedの「何に対して固定するか」を書き換えているのが原因です。しかも犯人になるプロパティは、モダンなUIを作ると自然に使うものばかりで、position とは無関係に見えます。

この記事では、なぜそうなるのかという仕組みから、犯人を機械的に特定する手順、そして実務で採れる4つの直し方までを、切り分けの順番どおりに書きます。

まず現象を再現する:ガラスヘッダーを作った日にドロワーが壊れる

実際に踏んだ形がいちばん早いので、最小構成で出します。よくある「半透明でぼかしの効いたヘッダー」と、その中に置いたハンバーガーメニューのドロワーです。

<header class="l-header">
  <button class="l-header__toggle" type="button" aria-expanded="false">MENU</button>
  <nav class="l-drawer" id="drawer">
    <ul class="l-drawer__list"><!-- 略 --></ul>
  </nav>
</header>
.l-header {
  position: sticky;
  top: 0;
  background: rgba(255, 255, 255, .7);
  backdrop-filter: blur(8px);   /* ← これが犯人 */
}

.l-drawer {
  position: fixed;
  inset: 0;                      /* 画面全面に広げたい */
  transform: translateX(100%);
  transition: transform .3s ease;
}
.l-drawer.is-open { transform: translateX(0); }

期待は「ドロワーが画面全面を覆う」。実際に出るのはヘッダーの高さ(実測で64px)の帯の中だけで開くドロワーです。inset: 0 は効いています。ただしその 0 が指しているのがビューポートの四辺ではなく、.l-header の四辺になっている。だから高さ64pxの箱になる。

backdrop-filter を1行コメントアウトすると、CSSを1文字も直さないまま全面に戻ります。ここで「ぼかしを消せば直る」で終わらせると、次に will-change を足した日に同じ場所でまた踏みます。

原因:fixedの基準(包含ブロック)が祖先にすり替わる

position: fixed の位置は、通常ビューポートを基準に決まります。この「何を基準に位置を計算するか」を包含ブロック(containing block)と呼びます。

ところが、祖先要素に次のいずれかが指定されていると、その祖先が新しい包含ブロックになり、配下の fixed はビューポートではなくその要素に対して固定されるという仕様があります。

  • transform が none 以外(translate / rotate / scale の個別プロパティも同じ)
  • filter が none 以外
  • backdrop-filter が none 以外
  • perspective が none 以外
  • will-change に transform / filter / perspective を指定(値を実際に変えていなくても、宣言した時点で効く)
  • contain: paint / contain: layout / contain: strict / contain: content、および content-visibility: auto(表示領域外のとき)

実務で踏む頻度が高い順に言うと、①アニメーション用の transform、②will-change の付けっぱなし、③ガラス表現の backdrop-filter です。どれも「親のレイアウトを変えるつもりが無い」宣言なので、fixedが壊れる原因として疑われにくい。

特に will-change は厄介で、パフォーマンス改善の常套句として .l-header { will-change: transform; } のように常時付けてあるコードをよく見ます。これは値が変化していない間もずっと包含ブロックを作り続けるので、静かに配下のfixedを壊します。GPUレイヤーも確保し続けるため、そもそも常時指定は推奨されません。

ついでにz-indexも効かなくなっている

transform や filter は、包含ブロックと同時にスタッキングコンテキストも作ります。そのため「fixedが効かない」と「z-index: 9999 を付けたのに他の要素の下に潜る」が同じ1行を原因として同時に起きることが非常に多い。両方の症状が出ていたら、まず祖先のtransform系を疑うのが最短です。重なり順そのものの切り分けは z-indexが効かない本当の理由と直し方 にまとめています。

犯人を特定する:コンソールに貼れる祖先スキャン

目視で祖先のCSSを追うのは、階層が深いテーマだと現実的ではありません。固定したい要素から上へ辿って、該当プロパティを持つ祖先を列挙するスニペットを用意しておくと、切り分けが数秒で終わります。開発者ツールのConsoleにそのまま貼って使います。

(function findBlocker(sel) {
  var el = document.querySelector(sel);
  if (!el) { console.warn('要素が見つかりません:', sel); return; }
  var hits = [];
  for (var n = el.parentElement; n && n !== document.documentElement; n = n.parentElement) {
    var s = getComputedStyle(n);
    var bad = [];
    if (s.transform !== 'none') bad.push('transform: ' + s.transform);
    if (s.filter !== 'none') bad.push('filter: ' + s.filter);
    if (s.backdropFilter && s.backdropFilter !== 'none') bad.push('backdrop-filter: ' + s.backdropFilter);
    if (s.perspective !== 'none') bad.push('perspective: ' + s.perspective);
    if (/transform|filter|perspective/.test(s.willChange)) bad.push('will-change: ' + s.willChange);
    if (s.contain && /paint|layout|strict|content/.test(s.contain)) bad.push('contain: ' + s.contain);
    if (bad.length) hits.push({ el: n, cause: bad.join(' / ') });
  }
  if (!hits.length) { console.log('包含ブロックを奪っている祖先はありません'); return; }
  console.log('%c犯人候補 ' + hits.length + '件(上が近い祖先)', 'font-weight:bold');
  hits.forEach(function (h) { console.log(h.cause, h.el); });
})('#drawer');

最後の引数を、固定したい要素のセレクタに変えるだけです。出力された要素にマウスを載せればページ上でハイライトされるので、テーマのどのパーツが原因かがそのまま分かります。

実案件のLP(セクションごとにスクロール連動アニメーションを入れていたもの)でこれを流したところ、犯人が3件出てきました。内訳は、アニメーション用ラッパーの will-change: transform、画像セクションの filter: saturate(1.05)、そしてヘッダーの backdrop-filter。手で追っていたら確実に1件目で止まって「直らない」となっていた形です。

直し方1:fixed要素をbody直下へ出す(第一選択)

もっとも確実で、副作用も少ないのがこれです。画面に固定するものは、レイアウトの階層の中に置かない。ドロワー、モーダル、追従CTA、トーストは </body> 直前にまとめて置き、ヘッダー内のボタンからはIDで操作します。

<header class="l-header">
  <button class="l-header__toggle" type="button" aria-controls="drawer" aria-expanded="false">MENU</button>
</header>

<!-- ページ末尾。どの祖先の影響も受けない -->
<nav class="l-drawer" id="drawer">…</nav>

WordPressのテーマで「ドロワーのマークアップがヘッダーのテンプレートに埋まっていて動かせない」場合は、JSで実行時に移動させる手もあります。1行で済みます。

document.body.appendChild(document.getElementById('drawer'));

ただし移動させると祖先由来のスタイルが外れる点には注意してください。.l-header .l-drawer { … } のような子孫セレクタで書いていた指定は当然効かなくなります。この機会に、固定系パーツのCSSは祖先に依存しない単独ブロックとして書き直すのが結果的に早いです。

直し方2:祖先のtransform・will-changeを外す/条件付きにする

デザイン都合で祖先の効果を消せないこともあります。その場合は「常時付ける」をやめます。

will-changeは、動かす直前に付けて、動き終わったら外す。これが本来の使い方です。CSSだけで完結させるなら、ホバーや操作の直前にだけ効かせます。

/* NG:常時付けっぱなし。配下のfixedが常に壊れる */
.l-card { will-change: transform; }

/* OK:動く直前だけ。終わればビューポート基準に戻る */
.l-card:hover,
.l-card:focus-within { will-change: transform; }

JSでアニメーションを制御している場合は、transitionend で外すのが確実です。

el.style.willChange = 'transform';
el.classList.add('is-move');
el.addEventListener('transitionend', function () {
  el.style.willChange = 'auto';   /* ここで包含ブロックが消える */
}, { once: true });

backdrop-filter のガラス表現をどうしても残したい場合は、ぼかしを掛ける層を1枚分けると両立できます。ヘッダー本体にはぼかしを付けず、内部に敷いた擬似要素や背景専用の子要素に backdrop-filter を持たせる形です。包含ブロックを作るのはその子要素だけになるので、ヘッダー直下に置いた固定要素は無事に済みます。

直し方3:モーダル用途ならdialogかPopoverに逃がす

そもそもモーダルやメニューなら、トップレイヤーに出る仕組みを使えば祖先の影響を受けません。<dialog> を showModal() で開いた場合、その要素はDOM上の位置に関わらず最前面のレイヤーに描画されます。祖先にtransformが何枚あっても関係ありません。

<dialog id="menu" class="c-modal">
  <button type="button" data-close>閉じる</button>
  <nav>…</nav>
</dialog>
var dlg = document.getElementById('menu');
document.querySelector('.l-header__toggle').addEventListener('click', function () {
  dlg.showModal();            /* トップレイヤーへ。背景スクロールも自動で抑止される */
});
dlg.querySelector('[data-close]').addEventListener('click', function () { dlg.close(); });

あわせて、背景の暗幕は ::backdrop で書けるので、オーバーレイ用のdivを自前で用意する必要もなくなります。Escキーでの閉じ、フォーカスの閉じ込め、背景要素の不活性化まで標準で付いてくるので、実装量はむしろ減ります。

ドロップダウンのように「画面を覆わない小さな浮遊物」なら、Popover API(popover 属性 + popovertarget)が同じくトップレイヤーに出ます。属性2つでJSゼロなので、fixed地獄に嵌まる前にこちらを検討する価値があります。

実装後に必ず一度は踏む落とし穴

  • 「効いていない」のではなく「ズレている」ときも同じ原因。追従バナーが画面右下ではなくセクションの右下に居座るパターン。これも包含ブロックがすり替わっているだけで、症状の見た目が違うだけです。
  • position: sticky が効かないのは別原因。stickyは祖先の overflow: hidden / auto / scroll と、親の高さ不足で死にます。fixedの話とは切り分けが違うので、同じ調査をしても出てきません。
  • スマホで固定要素の下端が隠れるのは、包含ブロックではなくビューポート単位の問題。100vh はアドレスバーを含んだ高さなので、100dvh との使い分けが要ります。詳細は スマホで100vhが画面に合わない原因とdvhの使い分け に書いています。
  • fixed要素が横スクロールバーを生む。width: 100% と padding の組み合わせや、translateX(100%) で画面外に退避させたドロワーが、overflow-x の指定次第でスクロール幅を作ります。切り分け手順は スマホで横スクロールが出る原因の特定手順 にまとめました。
  • iOSでドロワーを開くと背面が動くのは包含ブロックとは無関係の、スクロール連鎖の問題。overscroll-behavior や位置固定の退避で別途対処が要ります。
  • 印刷時だけ固定ヘッダーが全ページに刷られる。調査中に気づきにくい副作用なので、固定要素には @media print { position: static; } を最初から入れておくと後で困りません。

3分で終わる切り分けチェックリスト

  • 固定したい要素に position: fixed が実際に効いているか(開発者ツールのComputedで確認。position が static に戻されていないか)
  • 上のスニペットを流して、祖先に transform / filter / backdrop-filter / perspective / will-change / contain が無いか
  • 該当祖先が出たら、その宣言を一時的にコメントアウトして現象が消えるか(ここで消えれば確定)
  • 確定したら、要素をbody直下へ出す/祖先の宣言を条件付きにする/dialog・Popoverへ逃がす、のいずれかを選ぶ
  • 直したら、z-indexの重なり順も同時に直っているかを確認する(同じ原因で壊れていることが多い)

position: fixed は「画面に対して固定する」と覚えられがちですが、正確には「包含ブロックに対して固定する。ふつうはそれがビューポートなだけ」です。この一段だけ理解を足しておくと、fixedだけでなく、absolute の基準がどこへ行ったのか、z-index がなぜ効かないのかまで、同じ物差しで説明できるようになります。原因不明に見える現象のほとんどは、祖先が静かに作っている見えない箱が正体です。

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

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

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

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

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

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