はじめに
こんなコードを書いたことはありませんか?
<header class="site-header">ヘッダー</header>
<main>
<div class="card">
カード本文
<div class="modal">モーダル(z-index: 9999)</div>
</div>
</main>
.site-header {
position: relative;
z-index: 100;
}
.card {
transform: translateY(0); /* パフォーマンス改善のつもりで追加 */
}
.modal {
position: absolute;
z-index: 9999; /* こんなに大きくしたのに… */
}
.modal は z-index: 9999 を持っているのに、z-index: 100 のヘッダーより下に描画されてしまいます。「おかしい、9999 のほうが 100 より大きいのに」と z-index: 99999 に変えても、一向に解決しません。
この問題の原因は スタッキングコンテキスト(Stacking Context) にあります。
一度この概念を理解すると、z-index 絡みのバグで二度と悩まなくなります。
z-indexについての2つの誤解
先に「よくある誤解」を解消しておきましょう。
誤解① z-indexはすべての要素間でグローバルに比較される
z-index: 9999 が z-index: 100 より常に上に来る、と信じていませんか?
実際は「同じスタッキングコンテキスト内に属する要素間でのみ比較される」のです。異なるコンテキスト間では、z-index の数値はまったく意味を持ちません。
誤解② position + z-index を付ければ必ず効く
position: relative; z-index: 1; を付けて「これで前に出るはず」と思っていませんか?
z-index が効くのは確かにそのとおりですが、どの範囲に対して効いているのか、という問題があります。後述するスタッキングコンテキストの中にいる場合、コンテキストの外の要素とは比較されません。
スタッキングコンテキストとは
スタッキングコンテキストとは、z-index の比較が行われる独立した空間(スコープ)です。
現実世界に例えると、「国」のようなものです。各国には独自の順位(z-index)があり、国内での順位は他国には関係ありません。国と国を比べるときは、国そのものの「外交的地位」が使われます。
■ ページ全体(ルートのスタッキングコンテキスト)
│
├── ■ ヘッダー(z-index: 100)
│ └── ここには何もない
│
└── ■ .card(スタッキングコンテキストを生成)← transform により発生
└── ■ .modal(z-index: 9999)
↑ このz-indexは .card の内部でのみ有効
.modal の z-index: 9999 は .card というスタッキングコンテキストの中でしか比較されません。ページ全体で見ると、ヘッダー(z-index: 100)と比較されるのは .card 自身です。.card には z-index が指定されていないので、z-index: auto 扱いとなり、z-index: 100 のヘッダーより下に描画されます。
つまり .modal がいくら大きな z-index を持っていても、それは .card のコンテキスト内での話であり、ヘッダーとの比較には一切使われないのです。
z-index の比較は同じスタッキングコンテキストの親を持つ兄弟同士の間でのみ行われます。
スタッキングコンテキストが生成される条件
ここが最重要です。
これを知らないと、意図せずスタッキングコンテキストを生成してしまいます。
よく知られた条件
| プロパティ | 条件 |
|---|---|
position |
relative / absolute / fixed / sticky かつ z-index が auto 以外 |
position: fixed |
z-index の値に関わらず常に生成 |
position: sticky |
z-index の値に関わらず常に生成 |
意外な条件
| プロパティ | 条件 |
|---|---|
opacity |
1 未満のとき(opacity: 0.99 でも生成される!) |
transform |
none 以外のとき(translateY(0) でも生成される!) |
filter |
none 以外のとき |
backdrop-filter |
none 以外のとき |
perspective |
none 以外のとき |
clip-path |
none 以外のとき |
mask / mask-image
|
none 以外のとき |
mix-blend-mode |
normal 以外のとき |
isolation |
isolate のとき |
will-change |
上記いずれかの値を指定しているとき |
contain |
layout / paint / strict / content のとき |
flex/grid 子要素の特殊ルール
.flex-container {
display: flex;
}
.flex-item {
/* position 指定がなくても、z-index を指定するだけでスタッキングコンテキストが生成される */
z-index: 1;
}
通常、position を指定しない要素に z-index を指定しても無効です。しかし flex/grid コンテナの直接の子要素は例外で、position なしで z-index を指定するだけでスタッキングコンテキストが生成されます。
「ついやってしまう」コード例
パフォーマンス改善でよく書かれる以下のコードは、すべてスタッキングコンテキストを生成します。
/* アニメーション最適化のつもりで書いた */
.card {
transform: translateZ(0); /* ✗ スタッキングコンテキスト生成 */
will-change: transform; /* ✗ スタッキングコンテキスト生成 */
}
/* フェードインのつもりで書いた */
.overlay {
opacity: 0.95; /* ✗ スタッキングコンテキスト生成 */
}
/* ドロップシャドウのつもりで書いた */
.card {
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1)); /* ✗ スタッキングコンテキスト生成 */
}
スタッキングコンテキスト内での重ね順ルール
同一のスタッキングコンテキスト内では、以下の順序で下から上へ描画されます(数字が大きいほど手前)。
| 順序 | 内容 |
|---|---|
| 1 | スタッキングコンテキストのルート要素自体 |
| 2 |
z-index が負値の子スタッキングコンテキスト(小さい順) |
| 3 | ブロックレベルの子要素(position 指定なし) |
| 4 | フロートされた子要素 |
| 5 | インラインレベルの子要素 |
| 6 |
position 指定あり + z-index: auto または z-index: 0
|
| 7 |
z-index が正値の子スタッキングコンテキスト(大きい順) |
z-index: 0 と z-index: auto は別物
どちらも同じ優先度のように見えますが、z-index: 0 は新しいスタッキングコンテキストを生成し、z-index: auto は生成しません(position が fixed / sticky 以外の場合)。
よくある実例3パターン
パターン① モーダルがヘッダーの下に潜り込む
原因コード:
/* アニメーション目的で transform を付けた */
.page-wrapper {
transform: translateY(0);
}
.site-header {
position: sticky;
top: 0;
z-index: 100;
}
/* モーダルは .page-wrapper の子孫にある */
.modal {
position: fixed;
z-index: 9999;
}
.page-wrapper に transform があるので、スタッキングコンテキストが生成されます。position: fixed の要素は通常 viewport を基準に描画されますが、祖先にスタッキングコンテキストがある場合、そのコンテキストに閉じ込められます(これは position: fixed のもう一つの罠です)。
解決策:
/* transform が必要なら対象を限定する */
.page-wrapper {
/* transform: translateY(0); ← 削除 */
}
/* モーダルをスタッキングコンテキスト外に移動させる */
/* → Reactなら ReactDOM.createPortal() を使い、body の直下に描画 */
パターン② ドロップダウンがカードの下に隠れる
原因コード:
<div class="card">
<div class="dropdown">メニュー(z-index: 50)</div>
</div>
<div class="card card--next">
次のカード
</div>
.card {
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.1)); /* ← コンテキスト生成 */
}
.dropdown {
position: absolute;
z-index: 50;
}
.dropdown の z-index: 50 は .card 内のコンテキストで有効ですが、.card--next との比較では .card 自身の z-index(auto)が使われます。auto 同士はHTMLの出現順で後者が上になるため、ドロップダウンが次のカードの下に隠れます。
解決策:
/* filter を削除して別の方法でシャドウをつける */
.card {
/* filter: drop-shadow(...); ← 削除 */
box-shadow: 0 4px 12px rgba(0,0,0,0.1); /* box-shadow はコンテキストを生成しない */
}
パターン③ opacity を使ったフェードアニメーションでモーダルが消える
原因コード:
.hero-section {
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* .hero-section の子孫にモーダルがある */
.modal {
position: fixed;
z-index: 200;
}
アニメーション中、opacity が 1 未満になる瞬間があります。その間、.hero-section はスタッキングコンテキストを生成し、position: fixed の .modal を閉じ込めます。アニメーション終了後(opacity: 1)はコンテキストが消えて正常に戻りますが、アニメーション中はモーダルが隠れることがあります。
解決策:
/* フェードインは transform で代替する(opacity を 1 から変えない) */
.hero-section {
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* それでも困るなら、モーダルを body 直下に Portal で描画する */
解決策のレシピ
レシピ① isolation: isolate で意図的にコンテキストを作る
副作用(opacity・transform・filterなど)なしに、スタッキングコンテキストだけを生成できます。
/* コンポーネント内でz-indexを完結させたいとき */
.card {
isolation: isolate;
}
/* こうすることで .card 内の z-index は .card 外に影響しない */
.card .tooltip {
position: absolute;
z-index: 10; /* .card の外には影響しない */
}
これは「意図を明示する」書き方でもあります。「このコンポーネントは内部でz-indexを使うので、外とは独立させる」という設計意図がコードに現れます。
レシピ② CSS変数でz-indexを一元管理する
/* design-tokens.css */
:root {
--z-base: 0;
--z-dropdown: 10;
--z-sticky: 20;
--z-overlay: 30;
--z-modal: 40;
--z-toast: 50;
}
/* 使う側 */
.dropdown { z-index: var(--z-dropdown); }
.site-header { z-index: var(--z-sticky); }
.modal { z-index: var(--z-modal); }
.toast { z-index: var(--z-toast); }
数値が分散するとどの要素が何番なのか管理できなくなります。変数で一元管理すると「このモーダルはドロップダウンより上に来るべきか?」という設計上の意思決定がコードから読み取れます。
レシピ③ Reactでは createPortal() を使う
import { createPortal } from 'react-dom';
function Modal({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) {
if (!isOpen) return null;
// body の直下に描画することで、祖先のスタッキングコンテキストに影響されない
return createPortal(
<div className="modal-backdrop">
<div className="modal-content">{children}</div>
</div>,
document.body
);
}
Chrome DevTools でスタッキングコンテキストをデバッグする
z-index が効かないとき、スタッキングコンテキストの構造を可視化する方法です。
Layers パネルを使う
- DevTools を開く(F12 または ⌘+Option+I)
- 右上の「︙」→「More tools」→「Layers」を開く
- サイドバーにレイヤー一覧が表示される
スタッキングコンテキストを持つ要素はそれぞれ独立したレイヤーとして表示されます。意図しない要素がレイヤーになっていれば、そこがコンテキストを生成しています。
Elements パネルで直接確認する
怪しい要素を DevTools の Elements パネルで選択し、Computed タブを開きます。transform・opacity・filter などのプロパティが継承されていないか確認しましょう。
CSS Overview(Chrome 100+)を使う
- DevTools を開く
- 右上の「︙」→「More tools」→「CSS Overview」
- 「Capture overview」をクリック
ページ上のすべての z-index 値の一覧を確認できます。
まとめ:z-indexが効かないときのチェックリスト
□ 1. 問題の要素の祖先に opacity < 1 のものはないか
□ 2. 問題の要素の祖先に transform ≠ none のものはないか
(translateZ(0)、will-change: transform を含む)
□ 3. 問題の要素の祖先に filter ≠ none のものはないか
□ 4. 問題の要素の祖先に position: fixed / sticky のものはないか
□ 5. 比較したい2要素が同じスタッキングコンテキストの子になっているか
□ 6. Reactを使っているなら createPortal で body 直下に描画しているか
□ 7. 解決策として isolation: isolate が使えないか
z-index の数値を増やしても直らないときは、数値の問題ではなくコンテキストの構造の問題です。上のリストを順番に確認していけば、ほぼ確実に原因にたどり着けます。
参考
- MDN - スタッキングコンテキスト
- MDN - z-index
- Stacking context — What No One Told You About z-index (Philip Walton)
この記事が役に立ったら、ストックやいいねをいただけると励みになります!