結論:z-indexが効かないのは数字が小さいからではない。比べる土俵(スタッキングコンテキスト)が違う
- z-indexは同じスタッキングコンテキストの中でしか比較されない。別の文脈に入った要素は、中で何万を指定しても外の要素に勝てない
- 文脈を作る犯人は transform・opacity(1未満)・filter・will-change・position:fixed/sticky+z-index・isolation・mix-blend-mode・backdrop-filter
- まず祖先をたどって「文脈を作っている親」を特定する。数字を上げるのは最後
- モーダル/ドロワーはbody直下へ出すのが最短。transformの親の中にあるとposition:fixedの基準まで壊れる
- 9999を増やさない。CSS変数にレイヤー表を作り、生の数値をコンポーネントに書かない
「モーダルに z-index: 9999 を付けたのに、なぜか追従ヘッダーの下に潜る」「ドロワーだけ他の要素に負ける」——実装していて一番納得がいかないのがこの手のバグです。結論から言うと、原因は数字の大小ではなく、その要素がどの「土俵」で比較されているかにあります。この記事では、スタッキングコンテキストという土俵の正体、犯人の親を数秒で特定する方法、position: fixed まで巻き添えにする落とし穴、そして現場で二度と 9999 を書かずに済むレイヤー設計までを、実案件で確認している順番のまま整理します。
z-indexは「同じ土俵の中だけ」の順位付け
z-index は、ページ全体で通用する通し番号ではありません。同じスタッキングコンテキスト(重なり順を決める入れ物)の中にいる要素同士を並べるための番号です。
そして重要なのが次の点です。ある親がスタッキングコンテキストを作ると、その中の子要素は、どれだけ大きな z-index を持っていても、親という1つのカタマリとして外側と比較されるようになります。親が外側で3番目なら、中の子は何万を指定しても3番目のカタマリの中でしか動けません。
つまり z-index: 999999 が効かないとき、増やすべきは子の数字ではなく、子を閉じ込めている親を見つけて解放するか、親のほうの順位を上げるという対処です。数字の追加は、たいてい状況をさらに複雑にします。
スタッキングコンテキストを作ってしまうプロパティ一覧
「そんなつもりはなかったのに土俵が分かれていた」という事故は、たいてい以下のどれかが原因です。見た目の装飾やアニメーションのために書いたものが、重なり順の構造まで変えてしまっています。
position: relative / absolute+z-indexが auto 以外position: fixedとposition: sticky(z-index の指定がなくても作る)transformが none 以外(translateZ(0)やscale(1)でも該当)opacityが 1 未満(opacity: .999でも作る)filter/backdrop-filterが none 以外will-changeに transform や opacity を指定isolation: isolate/mix-blend-modeが normal 以外contain: paint/content-visibility: auto
実務で最頻なのは transform と will-change と opacity 未満1の3つです。私の案件では、スクロールでヘッダーを縮める処理を滑らかにしようとして親のラッパーに will-change: transform を1行足した日から、その中にあったドロワーだけが本文の画像に負けるようになりました。ドロワー側のCSSは1文字も触っていません。重なりが壊れたときは、直前にアニメーションや影・ぼかしを足していないかを疑うのが最短です。
犯人の親を数秒で見つけるスニペット
祖先を目視でたどるのは骨が折れるので、DevToolsのコンソールに貼って一覧で出します。隠れてしまう要素を選択した状態($0)で実行してください。
// 隠れる要素をElementsで選択してから実行
(function (el) {
const out = [];
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
const s = getComputedStyle(n);
const reasons = [];
if (s.transform !== 'none') reasons.push('transform');
if (parseFloat(s.opacity) < 1) reasons.push('opacity:' + s.opacity);
if (s.filter !== 'none') reasons.push('filter');
if (s.backdropFilter && s.backdropFilter !== 'none') reasons.push('backdrop-filter');
if (s.willChange !== 'auto') reasons.push('will-change:' + s.willChange);
if (s.isolation === 'isolate') reasons.push('isolation');
if (s.mixBlendMode !== 'normal') reasons.push('mix-blend-mode');
if (s.position === 'fixed' || s.position === 'sticky') reasons.push('position:' + s.position);
if (s.zIndex !== 'auto' && s.position !== 'static') reasons.push('z-index:' + s.zIndex);
if (reasons.length) out.push({ el: n, tag: n.tagName + (n.className ? '.' + String(n.className).split(' ')[0] : ''), reasons: reasons.join(' / '), z: s.zIndex });
}
console.table(out);
})($0);
出てきた一覧のいちばん下(=いちばん外側の祖先)が本命です。子の側にいくら指定があっても、外側で土俵が分かれていればそこで勝負が決まっています。この「最外の親から読む」という順番だけ覚えておけば、調査はほぼ一発で終わります。
直し方は3択。数字を上げるのは最後
① 要素を土俵の外へ出す(推奨)
モーダル・ドロワー・トースト・ツールチップのように画面全体に対して浮くものは、そもそもレイアウトの内側に置かないのが正解です。<body> 直下に移動すれば、どんな親の事情にも巻き込まれなくなります。
// マークアップ上は使う場所に書き、初期化時にbody直下へ移す
const modal = document.querySelector('.js-modal');
if (modal && modal.parentElement !== document.body) {
document.body.appendChild(modal);
}
② 犯人の親自身の順位を上げる
構造上どうしても出せない場合は、子ではなく親に z-index を与えて、カタマリごと持ち上げます。子の数字は据え置きで構いません。
③ 不要な文脈生成をやめる
will-change の付けっぱなしや、影を付けるためだけの opacity: .99 は外せることが多いです。will-change は「アニメーション直前に付けて終了後に外す」が本来の使い方で、CSSに常駐させると重なり順だけでなくメモリも消費します。
最悪の副作用:position:fixedの基準が親になる
ここが transform の一番怖いところです。transform を持つ祖先がいると、その中の position: fixed は画面ではなく「その親」を基準に配置されます。重なり順どころか、位置とサイズまで壊れます。
症状としては「全画面のはずのモーダル背景が、コンテンツ幅の中にしか広がらない」「スクロールするとモーダルが一緒に流れていく」という形で出ます。CSSを何度見直しても top:0; left:0; width:100% は正しいので、原因にたどり着くまでが長い部類のバグです。
スマホ向けのスライドインメニューを transform: translateX(100%) で隠している設計では、その親が常時 transform を持っていることになるため、中に別のfixed要素を置くと同じ事故が起きます。この場合も対処は①の「body直下へ出す」です。なお、閉じたドロワーが幅に数えられて横スクロールが出る件は別の症状なので、そちらは横スクロールの切り分け手順のほうを参照してください。
9999を増やさないレイヤー設計
その場しのぎで数字を足していくと、半年後には z-index: 100000 が複数箇所に散る状態になります。私は案件開始時に、レイヤー表をCSS変数で1か所に持ち、コンポーネント側には生の数値を書かないルールにしています。
:root {
--z-base: 0; /* 通常コンテンツ */
--z-sticky: 10; /* 追従する見出し・サイド */
--z-header: 100; /* グローバルヘッダー */
--z-drawer: 200; /* スライドインメニュー */
--z-overlay: 300; /* モーダルの背面オーバーレイ */
--z-modal: 310; /* モーダル本体 */
--z-toast: 400; /* 通知・トースト */
}
.l-header { z-index: var(--z-header); }
.c-drawer { z-index: var(--z-drawer); }
.c-modal { z-index: var(--z-modal); }
間隔を10〜100空けておくと、後から「ヘッダーとドロワーの間にもう1枚」が発生しても既存値を触らずに追加できます。数値を見るのは変数定義の1ファイルだけ、という状態にできるのが最大の利点です。
WordPressで作る場合、管理バーの z-index が 99999 である点も頭に入れておきます。ログイン中だけモーダルが管理バーの下に潜るのは正常な挙動なので、最終確認は必ずシークレットウィンドウで行ってください。ここは追従ヘッダーの位置ズレ確認と同じ落とし穴です。
そもそも競争から降りる:dialogのトップレイヤー
モーダルに限れば、<dialog> を showModal() で開くという選択肢があります。トップレイヤーという別枠に描画されるため、z-index の値に関係なく必ず最前面になります。背面クリックの無効化とEscでの閉じる挙動も標準で付いてきます。
<dialog class="c-modal" id="contactModal">
<p>お問い合わせ内容をご確認ください。</p>
<button type="button" class="js-close">閉じる</button>
</dialog>
const dlg = document.getElementById('contactModal');
document.querySelector('.js-open').addEventListener('click', () => dlg.showModal());
dlg.querySelector('.js-close').addEventListener('click', () => dlg.close());
/* 背面の暗幕は ::backdrop で指定する(別要素を作らない) */
/* dialog::backdrop { background: rgba(0,0,0,.6); } */
注意点は2つあります。dialog の初期状態は display: none 相当なので、開閉アニメーションは単純な opacity トランジションでは動きません(@starting-style か、クラス付与との併用が要ります)。もう1つ、開いている間の背面スクロールは止まりません。ここは通常のモーダルと同じ対処が必要なので、背面固定の実装手順はモーダルの記事側で解説している方法をそのまま使ってください。
公開前チェックリスト
- モーダル・ドロワーを開いた状態でスクロールし、追従ヘッダーの上に正しく載っていることを確認した
- モーダルの暗幕が画面いっぱいに広がっている(コンテンツ幅で止まっていない=transform親の混入なし)
- スマホ幅(375px)でドロワーを開き、ヘッダー・固定CTAバーとの前後関係を確認した
- 未ログイン(シークレットウィンドウ)で確認した。管理バーの 99999 に隠されていないか
- コンポーネントのCSSに生の数値の z-index が残っていない(変数経由になっている)
- アニメーション用の
will-changeが終了後に外れている、または常駐が必要な場所だけになっている
重なり順のバグは「CSSの知識不足」に見えて、実際は構造をどこに置くかという設計の話です。最初にレイヤー表を決め、浮くものは body 直下へ出す——この2つを最初にやっておくだけで、後から数字を足す作業がまるごと消えます。多言語サイトのように言語切替UIがヘッダーに乗り込んでくる構成では層がさらに増えるので、多言語サイトの作り方のような複数UIが同居するサイトほど早い段階で効いてきます。
なお、そもそもヘッダーやモーダルの挙動がテーマ・プラグイン側の実装に引きずられている場合は、CSSの調整では解決しません。その切り分けにはサーバー移行やテーマ環境まわりの確認が要りますし、表示速度が絡む前提条件はサーバー比較の記事もあわせてご覧ください。
多言語・ベトナム語サイトの制作でお困りではありませんか?
フォント・文字化け・WordPress多言語化・サーバー選定まで、多言語サイト制作の実績を持つ制作会社が一括対応します。お見積り・ご相談は無料です。
多言語サイト制作の詳細を見る →すぐに相談したい方は お問い合わせフォームへ
関連記事(コーディング実務Tips)
- モーダルの背面スクロールの正しい止め方
- position:stickyが効かない7つの原因
- 固定ヘッダーでアンカーリンクがずれる問題の直し方
- flexアイテムが縮まない・ellipsisが効かない原因はmin-width:auto
- height:autoがアニメーションしない理由と直し方|max-height・grid 0fr→1fr・JSの使い分け
- position:fixedが効かない原因と直し方|親のtransform/filterが包含ブロックを奪う
- CSSが効かない・上書きできない原因と直し方|詳細度・@layer・読み込み順|宣言そのものが届いているかを切り分ける入口。詳細度の数え方と@layerの優先順位を実測で整理しています
- transformが効かない・効きすぎる原因
- ボタンが押せない・リンクがクリックできない原因と直し方
- transitionが効かない原因と直し方