CSSのz-indexが効かない理由とisolationの使い方

​​CSS​HTML,JavaScript,Playwright

z-index: 9999 を付けても要素が前に出ないとき、まず確かめたいのは次の2つです。1つは、その要素が位置指定もされておらず、フレックスアイテムやグリッドアイテムでもないため、z-index がそもそも効いていない場合です。もう1つは、祖先のどれかが「重ね合わせコンテキスト」を作っていて、z-index がその内側でしか比べられていない場合です。後者の仕組みを逆に利用して、部品の中の重なりを外へ漏らさないようにするのが isolation: isolate です。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、ヘッドレスの Chromium 153 です。どの要素がいちばん上に見えるかは、document.elementFromPoint() で、重なった位置にある要素を調べて判定しました。Firefox と Safari、スマートフォンの実機では試していません。

z-index が効く要素と効かない要素

MDN の z-index のページでは、z-index が重なりの順番を決めるのは、位置指定された要素(position が static 以外)と、フレックスアイテム、グリッドアイテムだと書かれています。MDN の z-index の使い方のページの例にも、位置指定されていない要素の z-index は効果を持たない、とあります。

検証環境で、z-index: 10 を付けた要素と、その後ろに置いた position: absolute の要素(z-index は指定なし)を重ねました。

html
<div id="a" style="z-index: 10">a</div>
<div id="b" style="position: absolute; top: 50px; left: 50px">b</div>

上に見えたのは、z-index を付けていない b でした。a に position: relative を足すと、a が上になりました。

フレックスアイテムとグリッドアイテムは、position がなくても z-index が効きます。CSS Flexbox の仕様と CSS Grid の仕様には、position が static でも、z-index が auto 以外なら position: relative と同じように振る舞うと書かれています。検証環境でも、負のマージンで重ねた2つのフレックスアイテムのうち、前のほうに z-index: 1 だけを付けると、そちらが上になりました。同じマス目に置いた2つのグリッドアイテムでも同じでした。

祖先が重ね合わせコンテキストを作ると、外とは比べられない

位置指定もしているのに効かないときは、祖先を疑います。MDN の重ね合わせコンテキストのページによると、重ね合わせコンテキストの中の要素は、外の要素とは別に重ねられます。中の重ね合わせが済んだら、そのコンテキスト全体が、親のコンテキストの中で1つのまとまりとして扱われます。中の子にどれだけ大きな z-index を付けても、比べられるのは同じコンテキストの中の要素だけです。

検証環境で、z-index: 9999 の子を持つ親と、親の外にある z-index: 1 の要素を重ねました。

html
<div id="parent" style="position: relative">
  <div id="child" style="position: absolute; z-index: 9999">child</div>
</div>
<div id="other" style="position: absolute; top: 50px; left: 50px; z-index: 1">other</div>

親が position: relative だけのときは、上に見えたのは子(9999)でした。親に次のどれか1つを足すと、上に見える要素が、親の外の要素(1)に変わりました。

親に足した指定 上に見えた要素
なし(position: relative だけ) 子(z-index: 9999)
z-index: 0 または z-index: 1 外の要素(z-index: 1)
opacity: 0.99 外の要素
transform: translateZ(0) 外の要素
filter: blur(0) 外の要素
isolation: isolate 外の要素
will-change: transform 外の要素
contain: paint 外の要素

opacity: 0.99 や filter: blur(0) のように、見た目がほとんど変わらない指定でも結果は同じでした。MDN の重ね合わせコンテキストのページには、このほかにも、position: fixed と position: sticky、mix-blend-mode、clip-path、backdrop-filter、コンテナークエリー用の container-type など、重ね合わせコンテキストを作る条件が並んでいます。

z-index: 0 でも結果が変わる点にも注意が必要です。MDN の z-index のページにあるとおり、位置指定された要素では、z-index に整数を指定すると、値が 0 でも新しい重ね合わせコンテキストを作ります。auto のときは作りません。CSS Positioned Layout の仕様でも、relative と absolute の要素は、z-index が auto なら重ね合わせコンテキストを作らないとしています。

原因になっている祖先を探す

祖先が多いと、どれが重ね合わせコンテキストを作っているのかを目で探すのは大変です。私は、getComputedStyle() で祖先の計算済みのスタイルを順に調べる関数を書きました。ページに読み込んでから、調べたい要素を渡して呼び出します。

js
// 要素の祖先のうち、重ね合わせコンテキストを作っていそうなものを、よくある条件で調べる
function findStackingAncestors(el) {
  const found = [];
  for (let e = el.parentElement; e && e !== document.documentElement; e = e.parentElement) {
    const s = getComputedStyle(e);
    const parent = e.parentElement ? getComputedStyle(e.parentElement).display : "";
    const reasons = [];
    if (s.zIndex !== "auto" && (s.position !== "static" || /flex|grid/.test(parent)))
      reasons.push(`z-index: ${s.zIndex}`);
    if (s.position === "fixed" || s.position === "sticky") reasons.push(`position: ${s.position}`);
    if (Number(s.opacity) < 1) reasons.push(`opacity: ${s.opacity}`);
    for (const p of ["transform", "scale", "rotate", "translate", "filter", "backdrop-filter", "perspective", "clip-path", "mask-image"])
      if (s[p] && s[p] !== "none") reasons.push(`${p}: ${s[p]}`);
    if (s.mixBlendMode !== "normal") reasons.push(`mix-blend-mode: ${s.mixBlendMode}`);
    if (s.isolation === "isolate") reasons.push("isolation: isolate");
    if (/transform|opacity|filter|z-index/.test(s.willChange)) reasons.push(`will-change: ${s.willChange}`);
    if (/layout|paint|strict|content/.test(s.contain)) reasons.push(`contain: ${s.contain}`);
    if (/size/.test(s.containerType)) reasons.push(`container-type: ${s.containerType}`);
    if (reasons.length) found.push({ element: e, reasons });
  }
  return found;
}

検証環境では、この関数をスクリプトとしてページに読み込み、次のページの #target を渡して呼び出しました。

html
<main style="opacity: 0.99">
  <div class="layout" style="display: flex">
    <section class="panel" style="z-index: 2; transform: translateZ(0)">
      <div class="wrap" style="position: relative">
        <div id="target" style="position: absolute; z-index: 9999">menu</div>
      </div>
    </section>
  </div>
</main>

近い祖先から順に、次の2つが見つかりました。position: relative だけの div.wrap は、重ね合わせコンテキストを作らないので出てきません。

text
section.panel: z-index: 2, transform: matrix(1, 0, 0, 1, 0, 0)
main: opacity: 0.99

この関数は、MDN に挙がっている条件のうち、よく使うものだけを調べます。アニメーション中の値や、最上位レイヤーに置かれた要素などは見ていないので、見つからないときは MDN の一覧と照らし合わせてください。

isolation: isolate は、重ね合わせコンテキストを作るだけの指定

MDN の isolation のページによると、isolation: isolate を指定すると、その要素は必ず新しい重ね合わせコンテキストを作ります。初期値の auto では、ほかのプロパティが必要としたときにだけ作ります。

transform でも重ね合わせコンテキストは作れますが、transform には別の働きもあります。position: absolute の子が位置の基準にする箱(包含ブロック)を変えることです。検証環境で、margin: 100px の親の中に position: absolute; top: 0; left: 0 の子を置いて確かめました。親に何も付けないときと isolation: isolate を付けたときは、子はどちらもページの左上(0, 0)に置かれました。親に transform: translateZ(0) を付けると、子は親の左上(100, 100)に移りました。isolation: isolate を付けても、absolute の子の位置の基準は変わりませんでした。

z-index: -1 の飾りを、要素の背景の上に残す

isolation: isolate が役に立つ場面の1つが、飾りの要素を z-index: -1 で文字の後ろに回すときです。カードを position: relative にして、中に position: absolute; z-index: -1 の飾りを置くと、飾りはカードの白い背景の下に回り、隠れてしまいます。CSS Positioned Layout の仕様では、z-index が auto の relative の要素は重ね合わせコンテキストを作らず、その中の位置指定された子は、外側のコンテキストに加わるとしています。そのため飾りの -1 は、ページ全体のコンテキストで比べられます。

html
<div class="card">
  <div class="deco"></div>
  <p>カードの文字</p>
</div>
css
.card {
  position: relative;
  background: #fff;
  isolation: isolate; /* これがないと、飾りがカードの背景の下に隠れる */
}

.deco {
  position: absolute;
  z-index: -1;
  top: -14px;
  left: -14px;
  width: 260px;
  height: 110px;
  background: #d85878;
}
2つの画面を縦に並べたスクリーンショット。上の「カードに isolation なし」では、ピンクの飾りはカードの枠の外にはみ出した部分だけが見え、カードの中では白い背景に隠れている。下の「カードに isolation: isolate」では、ピンクの飾りがカードの白い背景の上に見え、「カードの文字」はその上に重なっている
z-index: -1 の飾りを持つカード。isolation なし(上)と isolation: isolate(下)

カードに isolation: isolate を付けると、飾りはカードの背景より上、文字より下に描かれました。検証環境で重なった位置を調べると、isolation がないときは、飾りの位置でいちばん上にあるのはカードでした。付けたときは飾りになり、文字の位置では文字が上のままでした。カードに z-index: 0 を付けても同じ結果でした。

isolation の中から出られない場面と、その直し方

区切った範囲の外には、中の z-index は届きません。カードの中にドロップダウンメニューを置く場合、これがそのまま問題になります。

2つの画面を縦に並べたスクリーンショット。上の「カード1に isolation: isolate だけ」では、カード1から下に伸びた z-index: 100 のメニューが、カード2の下に隠れている。下の「開いたカード1に z-index: 1 も付ける」では、メニューがカード2の上に重なり、項目Aと項目Bまで見えている
isolation: isolate のカードから伸びるメニュー。カード1に何も足さない場合(上)と、z-index: 1 を足した場合(下)

カード1とカード2はどちらも position: relative で、カード1にだけ isolation: isolate を付けました。カード1の中のメニューは z-index: 100 ですが、上の画面のように、後ろにあるカード2の下に隠れました。

検証環境で、いくつかの組み合わせを調べた結果は次のとおりです。

カード1 カード2 メニューとカード2の重なりで上に見えた要素
position: relative だけ position: relative メニュー
isolation: isolate position: relative カード2
isolation: isolate 指定なし(position: static) メニュー
isolation: isolate position: relative; z-index: 0 カード2
isolation: isolate と z-index: 1 position: relative メニュー

isolation を付けたカード1と、z-index が auto か 0 の位置指定されたカード2は、HTML に書いた順に描かれました。そのため、後ろに書いたカード2が上になりました。カード2が位置指定されていないときは、メニューが上に見えました。

開いている部品だけ z-index を上げる

直し方の1つは、メニューを開いているカードにだけ、外のコンテキストで比べられる z-index を付けることです。カード1に z-index: 1 を足すと、カード2より上になり、メニューも見えるようになりました。開いたメニューに open クラスを付ける作りなら、:has() でカードの側に z-index を付けられます。検証環境では、メニューを閉じているあいだのカード1の z-index は auto で、open クラスを足すと 1 になり、メニューがカード2の上に見えました。

css
.card {
  position: relative;
  isolation: isolate;
}

.card:has(.menu.open) {
  z-index: 1;
}

メニューを開いているあいだは z-index: 1 だけでも中の重なりは区切られますが、閉じているあいだは z-index が auto に戻ります。閉じているときも区切っておくには、isolation: isolate を残します。

popover で最上位レイヤーに出す

もう1つは、メニューを popover 属性付きの要素にして、最上位レイヤーに出す方法です。MDN の最上位レイヤーの解説によると、最上位レイヤーは、ページのほかのすべてのレイヤーより上に描かれる層です。

html
<div class="card">
  <div id="menu" popover="manual">…</div>
</div>
js
document.getElementById("menu").showPopover();

検証環境で、カード1を isolation: isolate のまま、カード2を position: relative; z-index: 9999 にしても、showPopover() で表示したメニューはカード2より上に見えました。

ただし、popover の要素はブラウザーの既定のスタイルで position: fixed になり、位置は画面を基準に決まります。位置を何も指定しないメニューを 800×600px の画面で表示すると、カードの下ではなく、画面の中央あたり(左上が (318, 233))に出ました。カードのすぐ下に出したいときは、位置を自分で指定する必要があります。

ブラウザーの対応

互換性データ(@mdn/browser-compat-data 8.1.8)では、isolation は Chrome 41、Edge 79、Firefox 36、Safari 8、iOS の Safari 8 から使えます。

​Posted by 86