z-indexが効かない原因のほとんどは、「祖先の要素がスタッキングコンテキスト(重なり順のグループ)を作っていて、z-indexの比較がそのグループの中に閉じ込められていること」です。 z-indexの数値は、同じスタッキングコンテキストの中にある要素どうしでしか比べられません。子に z-index: 9999 を付けても、親のグループ全体が z-index: 1 の兄弟より下なら、子も下に表示されます。

この記事では、スタッキングコンテキストを作るプロパティを一覧にし、それぞれを最小再現HTMLで作って、Playwright(Chromium 141.0.7390.37)の document.elementFromPoint で「どちらが前面か」を実測しました。MDNの一覧に載っていても、検証したブラウザでは作られなかったもの(container-type)も見つかっています。さらに、祖先のスタッキングコンテキストを洗い出すコンソール用JS(実行結果つき)と、isolation: isolate と CSS変数で z-index を管理する設計テンプレートも掲載します。

z-indexが効かない原因の早見表

症状 主な原因 直し方
z-indexを付けても何も変わらない 要素が position: static(初期値)で、flex / grid の子でもない position: relative を付ける、または親を flex / grid にする
子に9999を付けても兄弟の下になる 親(祖先)がスタッキングコンテキストを作っている 親側の z-index を上げる、または子を親の外(body直下など)に出す
アニメーション中だけ重なりが変わる transform・opacity・will-change が一時的に付く 常にコンテキストを作る指定(isolation: isolate)にそろえる
opacity: 0.99 を付けたら下に潜った opacity が1未満でコンテキストが作られた 親側で重なり順を決める
ドロップダウンがカードの下に隠れる カードの z-index がドロップダウンを含むヘッダーより大きい 本文に isolation: isolate、z-index はトークンで管理
ドロップダウンが切れて見える 重なりではなく、親の overflow: hidden で切り取られている z-index では直らない。表示位置を親の外へ(後述)
z-index: -1 の子が親の背景の裏に隠れる 親がコンテキストを作っていない 親に position: relative; z-index: 0 か isolation: isolate
モーダルが固定ヘッダーの下になる モーダルがヘッダーより低いグループの中にある モーダルを body 直下に置く、または <dialog> の showModal()

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「CSS z-indexが効かない|スタッキングコンテキスト原因一覧」

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

スタッキングコンテキストとは、重なり順を決めるときに「ひとまとまり」として扱われる要素のグループです。 グループの中の要素は、グループの外の要素と直接 z-index を比べません。グループ全体の順番がまず決まり、その中で子どうしの順番が決まります。

flowchart TD
  root["html(ルートのコンテキスト)"]
  A["親A:opacity: 0.99(コンテキストを作る/z-index は 0 扱い)"]
  A1["子:z-index: 999"]
  B["兄弟B:position: absolute; z-index: 1"]
  root --> A
  root --> B
  A --> A1

この図では、ルートの中で比べられるのは「親A(0扱い)」と「兄弟B(1)」です。子の 999 は親Aの中だけで有効なので、画面上は兄弟Bが前面になります。

同じコンテキストの中での描画順(後ろから前)は、おおまかに次のとおりです。

順番 描かれるもの
1(いちばん後ろ) コンテキストを作った要素の背景・枠線
2 z-index が負の子(-1 など)
3 通常のブロック要素
4 float 要素
5 インライン要素・テキスト
6 z-index: auto / 0 の位置指定要素、opacity などでコンテキストを作った要素(HTMLの登場順)
7(いちばん前) z-index が正の要素(値の小さい順)

opacity や transform で作られたコンテキストは、6番目の「z-index: 0 相当」に並びます。 z-index: 1 以上の兄弟があると、その下に入るのはこのためです。

スタッキングコンテキストを作るプロパティ一覧(実測つき)

次の表は、MDNの一覧をもとに、各プロパティを親要素に付けたときの前面の要素を実測したものです。 構成はすべて同じで、親の中に position: relative; z-index: 999 の子を置き、親の外に position: absolute; z-index: 1 の兄弟を重ねました。

  • 前面が「子(999)」→ 親はコンテキストを作っていない(子は外の兄弟と比べられる)
  • 前面が「兄弟(1)」→ 親がコンテキストを作り、子が閉じ込められた
親の指定 MDNの一覧 実測の前面 作る?
(比較)指定なし ― 子(999) 作らない
position: relative(z-index: auto) 対象外 子(999) 作らない
position: relative; z-index: 0 あり 兄弟(1) 作る
position: absolute; z-index: 0 あり 兄弟(1) 作る
position: fixed あり 兄弟(1) 作る(z-index なしでも)
position: sticky あり 兄弟(1) 作る(z-index なしでも)
opacity: 0.99 あり(1未満) 兄弟(1) 作る
transform: translateZ(0) あり 兄弟(1) 作る
translate: 0 0 あり 兄弟(1) 作る(値が0でも none 以外なら作る)
scale: 1 あり 兄弟(1) 作る
rotate: 0deg あり 兄弟(1) 作る
filter: blur(0) あり 兄弟(1) 作る
backdrop-filter: blur(1px) あり 兄弟(1) 作る
perspective: 100px あり 兄弟(1) 作る
clip-path: inset(...) あり 兄弟(1) 作る
mask-image: linear-gradient(...) あり(mask / mask-image / mask-border) 兄弟(1) 作る
mix-blend-mode: multiply あり(normal 以外) 兄弟(1) 作る
isolation: isolate あり 兄弟(1) 作る(見た目は変えない)
will-change: transform あり 兄弟(1) 作る
will-change: opacity あり 兄弟(1) 作る
will-change: top 対象外 子(999) 作らない
contain: paint あり 兄弟(1) 作る
contain: layout あり 兄弟(1) 作る
contain: size 対象外 子(999) 作らない
container-type: inline-size あり 子(999) Chromium 141 では作らなかった
container-type: size あり 子(999) Chromium 141 では作らなかった
content-visibility: auto (一覧外) 兄弟(1) 作る(中で paint 封じ込めが効くため)
view-transition-name: x (一覧外) 兄弟(1) 作る
flex アイテム+z-index: 0 あり 兄弟(1) 作る(position なしでも)
flex アイテム(z-index: auto) 対象外 子(999) 作らない
grid アイテム+z-index: 0 あり 兄弟(1) 作る(position なしでも)
overflow: hidden 対象外 子(999) 作らない

この表のほかに、トップレイヤー(<dialog> の showModal()、Popover API、全画面表示)に置かれた要素と ::backdrop、コンテキストを作るプロパティを animation-fill-mode: forwards でアニメーションさせた要素も、MDNではコンテキストを作るとされています。

実測で分かったポイント

  • 値が「何もしない値」でもコンテキストは作られます。 translate: 0 0・scale: 1・filter: blur(0) は見た目を変えませんが、none 以外なので作られました。
  • container-type は MDN の一覧にありますが、Chromium 141 では前面が子のままでした。 スクリーンショットの画素色(子の赤 rgb(255, 0, 0))でも確認しています。ブラウザやバージョンで差が出る可能性があるので、コンテナクエリを使う要素に重なり順を頼らず、必要なら isolation: isolate を明示しておくと安定します。
  • overflow: hidden はコンテキストを作りません。 ただし子をはみ出した部分で切り取るので、「重なりの下に隠れた」のではなく「切れて見えない」状態になります。z-index では直らないので、早見表のとおり表示位置を見直します。

計測に使ったコード

// zi.js(Node 22 + playwright 1.56.0、既存の Chromium を使用)
// 実行: node zi.js
const { chromium } = require('playwright');

const cases = [
  ['(比較)親に指定なし', ''],
  ['opacity: 0.99', 'opacity:0.99'],
  ['transform: translateZ(0)', 'transform:translateZ(0)'],
  ['isolation: isolate', 'isolation:isolate'],
  ['container-type: inline-size', 'container-type:inline-size'],
  ['flex アイテム+z-index: 0', 'z-index:0', 'flex'],
  // ……表の全パターンを同じ形で並べる
];

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage({ viewport: { width: 600, height: 800 } });
  const rows = [];
  for (const [name, css, wrap] of cases) {
    await page.setContent(`
      <style>
        body { margin: 0 }
        .t { position: relative; height: 160px; width: 300px }
        .parent { height: 40px; background: #eee }
        .child { position: relative; z-index: 999; height: 60px; background: tomato }
        .sib { position: absolute; top: 20px; left: 0; width: 300px; height: 60px; z-index: 1; background: steelblue }
      </style>
      <div class="t">
        <div style="${wrap ? 'display:' + wrap : ''}">
          <div class="parent" style="${css}"><div class="child" id="child"></div></div>
        </div>
        <div class="sib" id="sib"></div>
      </div>`);
    // 親の内側で、子と兄弟が重なる点(x=10, y=30)の前面要素を取得
    const id = await page.evaluate(() => document.elementFromPoint(10, 30).id);
    rows.push({ 親の指定: name, 前面: id === 'child' ? '子(999)' : '兄弟(1)' });
  }
  console.table(rows);
  await browser.close();
})();

判定の点を親の内側(y=30)にしているのは、contain: paint や mask-image が親の外側を切り取るためです。 親の外で測ると「切れて見えない」ことと「下に重なった」ことの区別がつかなくなります。

症状→原因→直し方のチェックリスト

上から順に確認すると、たいていの z-index の問題はここで見つかります。

No. 確認すること 確認方法 該当したら
1 その要素は位置指定されているか(または flex / grid の子か) Computed で position position: relative を足す
2 z-index が上書きされていないか Styles の打ち消し線 詳細度・読み込み順を見直す
3 比べたい2つの要素は同じコンテキストにいるか 下の「祖先を洗い出すJS」 共通の祖先の直下の子どうしで z-index を比べる
4 祖先に opacity / transform / filter / will-change がないか 同上 祖先側の z-index を上げる、または要素を外に出す
5 アニメーション中だけ重なりが変わらないか 動きの前後で比較 常時 isolation: isolate にしてコンテキストの有無をそろえる
6 切れて見えているだけではないか 祖先の overflow・clip-path・contain: paint 表示位置を親の外へ(body 直下・トップレイヤー)
7 負の z-index で親の背景の裏に回っていないか 親が コンテキストを作っているか 親に isolation: isolate

z-index が無視される例(position: static)

<div id="a" style="z-index: 999; height: 60px; background: red"></div>
<div id="b" style="position: relative; top: -30px; height: 60px; background: blue"></div>

実行結果(Chromium 141):

getComputedStyle(a).zIndex      → "999"
elementFromPoint(10, 40) の要素 → b

計算値は 999 のままですが、static の要素には効かないため、後ろに書いた b(relative)が前面です。 DevTools の Computed に値が出ていても、効いているとは限らない点に注意します。

z-index: -1 の子が親の背景に隠れる例

親の指定 子(position: absolute; z-index: -1)の位置での前面
position: relative のみ 親(子は親の背景より後ろ)
position: relative; z-index: 0 子(親のコンテキストの中で、親の背景の上)

負の z-index は「コンテキストを作った要素の背景のすぐ上」に描かれます。 親がコンテキストを作っていないと、さらに外側(ルート)基準になり、親の背景の裏に回ります。装飾用の疑似要素(::before に z-index: -1)を使うときは、親に isolation: isolate を付けておくと意図どおりになります。

DevToolsでの調べ方

DevTools では「どの祖先がコンテキストを作っているか」を直接一覧にする表示は限られるため、表示系のツールとコンソール用JSを組み合わせると早く見つかります。

ツール 手順 分かること
Chrome / Edge の Elements → Computed 要素を選び、position・z-index・opacity・transform を確認 その要素の計算値
Chrome / Edge の Elements → Styles 打ち消し線のついた z-index を探す 上書きされているか
Chrome / Edge の Layers パネル DevTools 右上のメニュー → More tools → Layers GPU合成レイヤーの分かれ方(will-change・transform の影響を見るのに便利。レイヤー=コンテキストではない点に注意)
Edge の 3D View More tools → 3D View → Z-index タブ 重なり順を立体表示で確認
コンソール 下の findStackingContexts() を貼り付けて実行 祖先のコンテキストと、作っている理由

祖先のスタッキングコンテキストを洗い出すコンソール用JS

Elements パネルで要素を選んでから findStackingContexts($0) を実行すると、その要素を閉じ込めている祖先が、理由つきで表に出ます。($0 は選択中の要素を指す DevTools の変数です。)

function findStackingContexts(el) {
  const rows = [];
  for (let node = el.parentElement; node; node = node.parentElement) {
    const s = getComputedStyle(node);
    const parentDisplay = node.parentElement ? getComputedStyle(node.parentElement).display : '';
    const isFlexOrGridItem = /flex|grid/.test(parentDisplay);
    const reasons = [];
    if (node === document.documentElement) reasons.push('ルート要素');
    if (['absolute', 'relative'].includes(s.position) && s.zIndex !== 'auto') reasons.push(`position:${s.position}+z-index:${s.zIndex}`);
    if (['fixed', 'sticky'].includes(s.position)) reasons.push(`position:${s.position}`);
    if (isFlexOrGridItem && s.zIndex !== 'auto') reasons.push(`flex/gridの子+z-index:${s.zIndex}`);
    if (parseFloat(s.opacity) < 1) reasons.push(`opacity:${s.opacity}`);
    if (s.mixBlendMode !== 'normal') reasons.push(`mix-blend-mode:${s.mixBlendMode}`);
    for (const p of ['transform', 'scale', 'rotate', 'translate', 'filter', 'backdropFilter', 'perspective', 'clipPath', 'maskImage']) {
      if (s[p] && s[p] !== 'none') reasons.push(`${p}:${s[p]}`);
    }
    if (s.isolation === 'isolate') reasons.push('isolation:isolate');
    if (/transform|opacity|filter|perspective|clip-path|mask|isolation|mix-blend-mode|z-index|translate|scale|rotate/.test(s.willChange)) reasons.push(`will-change:${s.willChange}`);
    if (/layout|paint|strict|content/.test(s.contain)) reasons.push(`contain:${s.contain}`);
    if (s.containerType && s.containerType !== 'normal') reasons.push(`container-type:${s.containerType}(ブラウザ差あり)`);
    if (reasons.length) {
      const name = node.tagName.toLowerCase() + (node.id ? '#' + node.id : '') + (node.classList.length ? '.' + [...node.classList].join('.') : '');
      rows.push({ 要素: name, 'z-index': s.zIndex, 理由: reasons.join(' / ') });
    }
  }
  console.table(rows);
  return rows;
}

// 使い方:Elements パネルで要素を選んでから
findStackingContexts($0);

次のHTMLで、ドロップダウン(#dd)を対象に実行しました。

<header class="site-header" style="position: sticky; top: 0; z-index: 100">
  <nav class="nav" style="display: flex">
    <div class="menu" style="transform: translateX(0)">
      <div class="card" style="opacity: .95">
        <ul class="dropdown" id="dd" style="position: absolute; z-index: 9999"><li>項目</li></ul>
      </div>
    </div>
  </nav>
</header>

実行結果(Chromium 141):

┌─────────┬──────────────────────┬─────────┬──────────────────────────────────────┐
│ (index) │ 要素                 │ z-index │ 理由                                 │
├─────────┼──────────────────────┼─────────┼──────────────────────────────────────┤
│ 0       │ 'div.card'           │ 'auto'  │ 'opacity:0.95'                       │
│ 1       │ 'div.menu'           │ 'auto'  │ 'transform:matrix(1, 0, 0, 1, 0, 0)' │
│ 2       │ 'header.site-header' │ '100'   │ 'position:sticky'                    │
│ 3       │ 'html'               │ 'auto'  │ 'ルート要素'                         │
└─────────┴──────────────────────┴─────────┴──────────────────────────────────────┘

いちばん近い祖先(index 0)から順に出るので、上の行から「この要素は本当にコンテキストが必要か」を見直します。 この例では、ドロップダウンの 9999 は div.card の中でしか効かず、ページ全体での順番はヘッダーの z-index: 100 で決まります。transform は値が matrix(...) で表示されますが、translateX(0) のように見た目が変わらない値も対象になります。

z-indexを変数で管理する設計テンプレート

z-index の問題を減らすいちばんの方法は、「ページ全体で比べる層」をトークン(CSS変数)で数個に限り、部品の内側は isolation: isolate で閉じ込めることです。 部品の中で何を指定しても外に影響しないので、9999 のような値の上げ合いがなくなります。

層 トークン 値の例 置く場所
ドロップダウン・ツールチップ --z-dropdown 100 ヘッダーやフォームの中
固定ヘッダー --z-header 200 body 直下
オーバーレイ(背景の暗幕) --z-overlay 300 body 直下
モーダル --z-modal 400 body 直下
トースト(通知) --z-toast 500 body 直下
部品の内側 0〜3 程度 ― isolation: isolate の中

モーダル・ドロップダウン・固定ヘッダーのコピペ例

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<style>
  :root {
    --z-dropdown: 100;
    --z-header: 200;
    --z-overlay: 300;
    --z-modal: 400;
    --z-toast: 500;
  }
  body { margin: 0; font: 16px/1.5 sans-serif; }

  /* 固定ヘッダー(fixed なのでコンテキストを作る) */
  .site-header {
    position: fixed; inset: 0 0 auto 0; height: 56px;
    background: #fff; border-bottom: 1px solid #ddd;
    z-index: var(--z-header);
  }
  .menu { position: relative; display: inline-block; margin: 12px; }
  .dropdown {
    position: absolute; top: 100%; left: 0; width: 200px; height: 160px;
    background: #fafafa; border: 1px solid #ccc;
    z-index: var(--z-dropdown); /* ヘッダーの中で比べる値 */
  }

  /* 本文:中の z-index を外に出さない */
  .main { isolation: isolate; padding-top: 56px; }
  .card { position: relative; z-index: 9999; height: 300px; background: #e8f0fe; margin: 0; }

  /* body 直下に置くもの */
  .overlay { position: fixed; inset: 0; background: rgb(0 0 0 / .4); z-index: var(--z-overlay); }
  .modal {
    position: fixed; top: 20px; left: 50%; translate: -50% 0;
    width: 320px; height: 200px; background: #fff; z-index: var(--z-modal);
  }
  .toast {
    position: fixed; right: 16px; bottom: 16px; width: 240px; height: 48px;
    background: #333; color: #fff; z-index: var(--z-toast);
  }
</style>
</head>
<body>
  <header class="site-header" id="header">
    <div class="menu">
      <button id="menuBtn">メニュー</button>
      <div class="dropdown" id="dropdown">ドロップダウン</div>
    </div>
  </header>
  <main class="main">
    <p class="card" id="card">カード(ライブラリ由来の z-index: 9999)</p>
  </main>
  <!-- 開いたときに body 直下へ追加 -->
  <!-- <div class="overlay"></div><div class="modal">モーダル</div><div class="toast">保存しました</div> -->
</body>
</html>

動作確認の結果(Playwright / Chromium 141、画面 800×500)

確認 座標 前面の要素
ドロップダウンとカード(9999)が重なる点 (30, 150) dropdown
同じ点、.main の isolation: isolate を外した場合 (30, 150) card(9999 がヘッダーの 200 を上回る)
ヘッダー位置 (600, 30) header
モーダル表示中、モーダルとヘッダーが重なる点 (400, 40) modal
モーダル表示中、モーダル外のヘッダー位置 (40, 30) overlay(暗幕がヘッダーを覆う)
トースト位置 (700, 460) toast
<dialog> を showModal() で開いた後、トースト位置 (700, 460) dialog(トップレイヤーの ::backdrop が覆う)

.main に isolation: isolate があるだけで、カードの 9999 が本文の中に閉じ込められ、ドロップダウンが前面になりました。 外すとカードが前に出ることも確認できます。

トップレイヤー(dialog・popover)を使う

<dialog> を showModal() で開くと、z-index の値に関係なく「トップレイヤー」という最前面の層に表示されます。 実測でも、z-index: 500 のトーストより前に ::backdrop が表示されました。モーダルやメニューは、HTMLの dialog 要素や popover 属性を使うと、z-index の管理対象から外せます。

<button type="button" id="open">開く</button>
<dialog id="dlg">
  <p>タスクを削除しますか?</p>
  <form method="dialog"><button>閉じる</button></form>
</dialog>
<script>
  document.getElementById('open').addEventListener('click', () => {
    document.getElementById('dlg').showModal();
  });
</script>

親の overflow: hidden で切れるときの考え方

ドロップダウンが「隠れる」のではなく「切れる」ときは、z-index ではなく配置の問題です。 祖先に overflow: hidden・clip-path・contain: paint があると、その外には描かれません。メニュー本体を body 直下に移す(ポータル)か、Popover API でトップレイヤーに出すと解決します。なお position: sticky が効かない問題は別の仕組みなので、ここでは扱いません。

よくある質問

Q. z-index の最大値はいくつですか? A. 実装上は 32ビット整数の範囲(2147483647 まで)が使われます。ただし、値を大きくしても別のコンテキストの要素には勝てないので、大きな値に頼らず、トークンで層を決める方が確実です。

Q. transform を付けたら z-index が効かなくなったのはなぜですか? A. transform(translate・scale・rotate も同じ)が none 以外になると、その要素がスタッキングコンテキストを作り、子の z-index が中に閉じ込められるためです。実測でも translate: 0 0 や scale: 1 のような見た目を変えない値で作られました。

Q. isolation: isolate はどんなときに使いますか? A. 見た目を変えずにコンテキストだけを作りたいときに使います。部品のルート要素に付けると、中の z-index が外に影響しなくなります。z-index: -1 の疑似要素を親の背景の上に出したいときにも便利です。

Q. flex の子に z-index を付けるときも position は必要ですか? A. 不要です。flex アイテムと grid アイテムは、position: static のままでも z-index が効きます。実測でも、z-index: 0 を付けた flex アイテム・grid アイテムはコンテキストを作りました。

まとめ

  • z-index は同じスタッキングコンテキストの中でしか比べられない。子の 9999 は親のグループの外には効かない
  • opacity・transform 系・filter・will-change・contain・isolation・mix-blend-mode・mask・clip-path・perspective・backdrop-filter、fixed / sticky などがコンテキストを作る(値が見た目を変えなくても作る)
  • container-type は MDN の一覧にあるが Chromium 141 では作られなかった。重なりを頼らず isolation: isolate を明示
  • 調べるときは findStackingContexts($0) で祖先を一覧にし、近い祖先から見直す
  • ページ全体の層はトークン(CSS変数)で数個に限り、部品は isolation: isolate で閉じ込める
  • モーダルは <dialog> の showModal() でトップレイヤーへ。切れるときは overflow の問題として配置を見直す

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「CSS z-indexが効かない|スタッキングコンテキスト原因一覧」

株式会社RJCでは、CSSの重なり順の仕組みを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?