z-indexが効かない5つの原因と直し方(スタッキングコンテキストをデモで理解する)

症状:z-indexを大きくしても要素が手前に来ない

モーダルやドロップダウンを最前面に出したくて z-index: 9999 を付けたのに、別の要素の下に隠れたまま出てこない。値をさらに大きくしても変わらない。この「いくら大きくしても効かない」は、z-indexで最も多いつまずきです。

原因のほとんどは、z-indexが「同じスタッキングコンテキストの中でしか順番を比べない」というルールにあります。親要素が知らないうちにスタッキングコンテキストを作っていると、子のz-indexはその親の内側に閉じ込められ、外の要素とは値を比べてもらえません。この記事は、その仕組みをチェックボックスで切り替えられるデモで確かめながら直していきます。

効かない原因は次の5つ

実務で遭遇するz-indexの不発は、ほぼ次の5パターンに収まります。上から順に確認してください。原因2〜4は「親がスタッキングコンテキストを作っている」という同じ根っこの、条件違いです。

原因1:position が static のまま(z-indexが完全に無視される)

z-indexは positionstatic 以外(relative / absolute / fixed / sticky)のときだけ効く。position未指定の要素にいくらz-indexを書いても無視される。

原因2:親に transform が付いている

親に transform が指定されていると、その親が新しいスタッキングコンテキストを作る。子のz-indexは親の中でしか効かず、親の外の要素より前には出られない。

原因3:親に opacity が1未満で付いている

opacity: 0.99 のようにわずかでも1未満だと、その親はスタッキングコンテキストを作る。transformと同じく子が閉じ込められる。

原因4:親に filter が付いている

filterblurdrop-shadow など)も親をスタッキングコンテキスト化する。値が実質ゼロの filter: blur(0) でも同じく作られる。

原因5:兄弟のスタッキングコンテキストの前後関係で負けている

2つの親(兄弟)にそれぞれz-indexが付いているとき、順番は親どうしのz-indexで決まる。後ろの親の中の子は、いくらz-indexを上げても前の親より手前には来られない。

他にも will-changeisolation: isolateposition: fixed / sticky 自体などがスタッキングコンテキストを作りますが、実務でハマる大半は上の transformopacityfilter です。以下、原因ごとに失敗デモと修正デモ、確認手順を付けています。

そもそもスタッキングコンテキストとは

スタッキングコンテキストは、要素の重なり順を決める「独立したグループ」です。ページ全体はまずルート(html)の1つの大きなグループから始まり、特定の条件を満たした要素が、その内側に小さなグループを作ります。

重なり順の比較は、この同じグループの中でしか行われません。あるグループ全体が別のグループの後ろに置かれたら、中の子がどんなに大きなz-indexを持っていても、グループごと後ろに沈みます。z-indexは「クラス内の席順」で、スタッキングコンテキストは「クラスそのもの」だと考えると分かりやすいです。別のクラスの生徒とは、そもそも席順を比べません。

z-index: 9999 は「このグループの中で一番前」を意味するだけ。グループごと後ろに置かれていたら、9999でも前には出られない。

原因1:position が static のまま

一番単純な見落としです。z-indexは positionstatic(初期値)の要素では完全に無視されます。relative / absolute / fixed / sticky のいずれかを付けて初めてz-indexが意味を持ちます。

下のデモは、青い箱Aに z-index: 9999 を付けて手前に出したいのに、あとに置いたオレンジの箱Bに隠れている状態です。左は箱Aが position: static、右は position: relative です。

効かないパターン(position 指定なし)

箱Aに z-index: 9999 を書いても、position が static なので無視され、あとに置いたオレンジの下に隠れます。

箱A
z-index: 9999
position: static
箱B
(あとに配置)
効くパターン(position: relative を追加)

position を付けると z-index が有効になり、箱Aがオレンジより手前に出ます。

箱A
z-index: 9999
position: relative
箱B
(あとに配置)
/* 効かない:position が static だと z-index は無視される */
.box-a {
  z-index: 9999;
}

/* 効く:position を付けて初めて z-index が有効になる */
.box-a {
  position: relative; /* relative / absolute / fixed / sticky のいずれか */
  z-index: 9999;
}

自分のケースか確認する方法:DevToolsで対象要素を選び、Computedパネルで position を見ます。static と表示されていたらこの原因です。z-indexの行がグレーアウト(適用されていない表示)になっていることも多いので、あわせて確認してください。

原因2〜4:親のtransform / opacity / filter(触って確かめるデモ)

ここが本題です。子要素のpositionもz-indexも正しいのに手前に出ないとき、犯人はたいてい親要素です。親に transformopacity(1未満)や filter が付いていると、親が新しいスタッキングコンテキストを作り、子のz-indexはその内側だけの席順になってしまいます。

下のデモは、青い箱A(z-index: 9999)を親の中に置き、オレンジの箱B(z-index: 1)を親の外に置いた構成です。チェックボックスで親のプロパティをON/OFFすると、箱Aの効きが切り替わります。実際に触って、どのプロパティで箱Aが沈むか確かめてください。

親のプロパティを切り替えて挙動を見る
箱A(親の中)
z-index: 9999
箱B(親の外)
z-index: 1

現在:どのプロパティもOFF → 箱A(9999)が箱B(1)より手前。正しく効いています。

チェックを1つでも入れると、箱Aは z-index: 9999 のまま、たった z-index: 1 の箱Bの下に隠れます。親がスタッキングコンテキストを作ったことで、箱Aの9999は「親の中での順位」に格下げされ、親そのものが(z-index未指定なので)箱Bより後ろに置かれるからです。値の大小ではなく、所属するグループで負けています。

直し方1:親から不要なtransform / opacity / filterを外す

もっとも素直な対策は、原因になっているプロパティを親から取り除くことです。ホバー演出のために付けた transform や、フェード用に静的に付けた opacity: 0.99 が、実は不要だったというケースは珍しくありません。

/* 犯人:親に transform が付いてスタッキングコンテキストを作っている */
.parent {
  transform: translateZ(0); /* これが子の z-index を閉じ込める */
}

/* 対策1:不要なら外す */
.parent {
  /* transform を削除。子の z-index が外の要素と比べられるようになる */
}

直し方2:外せないなら、親のスタッキング順を制御する

アニメーションやGPU合成のために transform をどうしても残したい場合は、親を消すのではなく、親どうしの重なり順を整える方向で直します。前面に出したい要素の親に position と高い z-index を与えれば、グループごと手前に持ち上がります。子のz-indexを上げるのではなく、親のz-indexを上げるのがポイントです。

/* 対策2:手前に出したい要素の「親」に z-index を与える。
   子ではなく親を持ち上げる */
.parent {
  transform: translateZ(0); /* 演出上どうしても必要なら残す */
  position: relative;
  z-index: 100;   /* 親グループごと手前へ */
}

自分のケースか確認する方法:効かないz-index要素から親を1つずつさかのぼり、DevToolsのComputedパネルで各親の transform / opacity / filter / will-change を確認します。none1 以外の値が見つかったら、それがスタッキングコンテキストを作っている候補です。該当プロパティを一時的に消してz-indexが復活するかで、犯人を確定できます。

原因5:兄弟のスタッキングコンテキストの前後関係で負けている

親が犯人ではなく、親どうしの順番で負けているパターンです。z-indexを持つ2つの要素(兄弟)があると、その中の子の重なりは、まず兄弟どうしのz-indexで決まります。後ろの兄弟の中にいる子は、いくらz-indexを上げても、前の兄弟より手前には出られません。

下のデモは、オレンジのカード(z-index: 2)の中に濃いオレンジの子(z-index: 9999)を置き、青いカード(z-index: 5)を重ねた状態です。子の9999は青(5)に勝てず、青の下に隠れます。

効かないパターン(後ろの親の子は前へ出られない)

濃いオレンジの子は z-index: 9999 ですが、親のオレンジ(2)が青(5)より後ろなので、子も青の下に隠れます。

親オレンジ
z-index: 2
子 z-index: 9999
親ブルー
z-index: 5
直したパターン(子ではなく親のz-indexを上げる)

オレンジの親を青より大きい z-index にすれば、子も含めて手前に出ます(下のコードのとおり親側を直します)。

親オレンジ
z-index: 10
子 z-index: 1 でもOK
親ブルー
z-index: 5
/* 効かない:子の z-index をいくら上げても、
   後ろの親(z-index: 2)の中に閉じ込められている */
.parent-orange { position: relative; z-index: 2; }
.parent-orange .child { position: relative; z-index: 9999; } /* 無力 */
.parent-blue  { position: relative; z-index: 5; }

/* 直す:親どうしの順番を直す。子ではなく親を持ち上げる */
.parent-orange { position: relative; z-index: 10; } /* 青より大きく */

自分のケースか確認する方法:効かないz-index要素と、その上に被さっている要素の、それぞれの祖先をたどって、最初にz-indexを持つ親を見つけます。その親どうしのz-indexを比べて、自分側が小さければこの原因です。直すのは子ではなく、その親のz-indexです。

直らないときの切り分け手順

5つのデモを見ても直らない場合は、次の順で機械的に確認します。z-indexのデバッグは「値を上げる」より「グループを探す」のが本筋です。

手順1:positionを確認する

Computedパネルで position を見る。static ならまずここを直す(原因1)。

手順2:親をさかのぼってスタッキングコンテキスト源を探す

各親の transform / opacity / filter / will-change / isolation を確認し、none1 以外を探す(原因2〜4)。一時的に消して復活するかで犯人を確定する。

手順3:親どうしのz-indexを比べる

自分の要素と被さっている要素の、最初にz-indexを持つ親どうしを比較する。負けていれば、直すのは親のz-index(原因5)。

まとめ

z-indexが効かないときは、値を大きくするのをやめて、「どのスタッキングコンテキストに閉じ込められているか」を探すのが近道です。確認する順番は次のとおりです。

positionがstaticでないか → 親にtransform・opacity・filterが付いていないか → 親どうしのz-indexで負けていないか。

この3点を順に潰せば、実務で出会うz-indexの不発はほぼ解消できます。z-index: 9999は「グループ内で一番前」でしかない、という一点さえ押さえておけば、次からは値ではなく親を疑えるようになります。モーダルやドロップダウンを確実に最前面へ出す実装は、下の関連事例で確認できます。

同じシリーズの関連記事

「効かない・動かない」トラブル解決シリーズの記事です。この記事はシリーズの集大成で、症状から原因を切り分けたいときにあわせてどうぞ。

関連するUI事例

最前面に重ねるオーバーレイUIを、実際に動くコードで確認できる事例です。z-indexとスタッキングコンテキストの実装例としても参考になります。