- CSS
CSS z-indexが効かない|スタッキングコンテキスト原因一覧
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期連続の黒字決算。