概要
CSSでモーダルウィンドウやドロワーを実装するとき z-index: 9999 といった大きな値を見かけることがあります。
「とにかく上に出したい」という意図での記載かもしれませんが、値を上げていくだけでは根本的な解決にならず、やがて管理しにくくなっていきます。
そこで z-index の値をほぼ1と0(auto)1に絞り、スタッキングコンテキストの設計と最上位レイヤーの活用でUIを組む方法を試してみました。
デモは次のリポジトリにあります。
対象読者
- z-indexの値の決め方に迷ったことがある方
- モーダルウィンドウやフローティング要素の
z-index管理に手を焼いた経験がある方 -
isolation: isolateや最上位レイヤーをまだ使っていない方
最初にまとめ
- UIブロックを適切な単位で
isolation: isolateを用いてスタッキングコンテキスト的に分離すればz-indexの値は1かautoの「ほぼboolean」な使い分けで表現しやすくなる- 意図的に背面に沈めたい要素についてのみ-1を使用する
- 同じスタッキングコンテキスト内で同じ
z-indexの要素が重なる場合はDOM順で解決できる- 数値を変える必要はない
- 必ず最前面であってほしいモーダルウィンドウは
<dialog>+showModal()の最上位レイヤーに任せることでz-indexを指定しなくても良い
環境
- SvelteKit 2 / Svelte 5
- Tailwind CSS 4
この話題が出たプロジェクトがSvelte + Tailwind CSSだったので、それでデモを作っています。
内容は素のHTML + CSSと変わらないため、自分の使っている技術に置き換えて読み替えてもらえると嬉しいです。
デモでz-10 / -z-10を使っているのは、Tailwind CSSの標準クラスを活かしているだけです。
本題
なぜ z-index は大きな数値になりがちで、何を引き起こすのか
z-index の値が 9999 などになってしまう原因のほとんどは、スタッキングコンテキストを意識せずに「大きな値を指定すれば上に出る」という考えで対処を重ねた結果かと思います。
実際、チャットボットを提供する有名ライブラリが、フローティングボタンの z-index に21億程度の値を指定していたのを見たことがあります。
あまり詳しくないうちにそういう実装を目の当たりにしてしまったら、前面に出したいものはとにかく数値を大きくすれば良いんだと勘違いしてしまうのも無理はありません。
そのような考え方で作っていた場合、どの要素をどのレイヤーに表示するか細かく制御するのは難しくなってしまいます。
結果「そこまで重要でない要素がCall to Action要素の上に被ってしまっていて全然見えない」といった状況などを引き起こしてしまいます。
今回定義した重なり順
今回のデモでは、UIの重なり順を以下のように定義しています。
- モーダルウィンドウ、ダイアログなど
- 固定ヘッダーとそのメニュー
- トースト
- フローティングボタン
- テーブルの固定列
- 通常の要素
- 装飾で背景に行ってほしい要素
1は最上位レイヤーに配置するため、z-indexの指定は不要です。
2〜5は z-index を1(デモでは z-10)に設定します。
それぞれが別のスタッキングコンテキストに属しているか、同じスタッキングコンテキスト内でもDOM順で重なりが決まるため、同じ値でも干渉しません。
6は z-index を指定しません(auto)。
7は z-index を-1(デモでは -z-10)に設定します。
section 外に影響しないよう isolation: isolate と組み合わせて使います。
isolation: isolate で section を分離する
デモのメインページでは、3つの section すべてに isolate(Tailwind CSSでの isolation: isolate)を指定しています。
<section class="relative isolate flex items-center bg-white">
...
</section>
<section class="isolate px-6 py-8">
...
</section>
<section class="isolate bg-white px-6 py-8">
...
</section>
isolation: isolate は、他のプロパティの副作用に頼らず2、スタッキングコンテキストを明示的に生成するためのプロパティです。
これにより、各 section 内の z-index はその section の中だけで完結し、外側の要素と干渉しなくなります。
section 自体には z-index を指定していないため、 section 外に置いた z-10 の要素は、 section 内の要素の z-index がどれだけ大きくても、それより上に表示されます。
固定ヘッダー
ページ上部のヘッダーは sticky + z-10 で実装しています。
<header class="sticky top-0 right-0 left-0 z-10 bg-white px-6 py-3 shadow-md">
...
</header>
ヘッダーはどの section にも属さず、ページのスタッキングコンテキストに直接置かれます。
なお position: sticky はそれ自体でスタッキングコンテキストを作ります。ここではそのうえで z-10 を指定し、ページ直下の要素として前面に置いています。
section 自体のz-indexは auto であるため、ヘッダーの z-10 が確実に上に表示されます。
ドロワーメニュー
ドロワーメニューは背景要素とパネルがどちらも z-10 です。
{#if drawerOpen}
<!-- 背景要素: fixed z-10、DOMでパネルより前 → パネルより下 -->
<div
class="fixed inset-0 z-10 bg-black/50 backdrop-blur-sm"
role="presentation"
onclick={() => (drawerOpen = false)}
></div>
<!-- パネル: fixed z-10、DOMで背景要素より後 → 背景要素より上 -->
<div class="fixed top-0 right-0 bottom-0 z-10 flex w-72 flex-col bg-white shadow-2xl">
...
</div>
{/if}
同じスタッキングコンテキスト内で、同じスタックレベルにある要素同士であれば、後から記述された要素の方が上に表示されます。
背景要素をパネルより先に書いているため、パネルが上に来ます。
「背景要素は z-20、パネルは z-30」のように数値で差をつける必要はありません。
トースト
トーストは fixed + z-10 で画面の固定位置に表示します。
<div
class="fixed top-16 right-4 z-10 ..."
role="status"
aria-live="polite"
>
...
</div>
ヘッダーと同様に、どの section にも属さずページのスタッキングコンテキストに置かれます。
position: fixed もスタッキングコンテキストを作るため、ここでもページ直下の要素として重なり順を管理できます。
DOM順ではヘッダーより前に書かれているため、ヘッダーが上に来ます。
これは「固定ヘッダーとそのメニュー」が「トースト」より優先される設計と一致しています。
フローティングボタン
フローティングボタンは section の内側に置かれた sticky + z-10 の要素です。
<section class="isolate px-6 py-8">
...
<div class="sticky bottom-4 z-10 -mx-2 grid py-3">
<FloatingButton onclick={() => (modalOpen = true)} />
</div>
</section>
section が isolate で閉じているため、この FloatingButton の z-10 は section 内でのみ有効です。
複数の section に同じ FloatingButton を置いても、それぞれのスタッキングコンテキスト内で完結するため互いに干渉しません。
section 自体の z-index は auto であるため、 section 外に置いたヘッダーやトーストが確実に上に表示されます。
テーブルの固定列
横スクロール可能なテーブルの見出し列を固定する場合も z-10 を使います。
<th class="sticky left-0 z-10 ...">名前</th>
...
<td class="sticky left-0 z-10 ...">...</td>
section の isolate が指定されていない場合、見出し列の z-10 はページのスタッキングコンテキストで評価されます。
DOM順ではヘッダーより後に書かれているため、同じ z-10 でも見出し列が上に来てしまいます。
結果として、スクロール中に見出し列が固定ヘッダーを突き抜けて表示されてしまいます。
isolate を指定することで section 自体が新しいスタッキングコンテキストを持ちます。
section 自体の z-index は auto であるため、ページのスタッキングコンテキストではヘッダーの z-10 より下に位置します。
つまり見出し列の z-10 は section 内に閉じ込められ、固定ヘッダを突き抜けることがなくなります。
背景の装飾要素
section の背景に装飾画像を置く場合などは例外的に -z-10 を使います。
<section class="relative isolate flex items-center bg-white">
<div class="absolute top-10 right-0 bottom-10 left-16 -z-10">
<img src={decoration} alt="" class="h-full w-full object-cover" />
</div>
...
</section>
isolate が無い場合、-z-10 を持つ要素は親のスタッキングコンテキストを突き抜け、 section の背景よりも後ろに配置されて見えなくなってしまいます。
isolate で section 単位のスタッキングコンテキストを作ることで、-z-10 の要素は section 内の最背面にとどまります。
<dialog> + showModal() でモーダルウィンドウを最上位レイヤーに置く
z-index で難しいのが、どのスタッキングコンテキストよりも確実に上に出したいケースです。
モーダルウィンドウがその典型です。
このデモでは <dialog> 要素と showModal() を使い、 z-index を指定せずにモーダルウィンドウを実装しています。
<script lang="ts">
interface Props {
open: boolean;
onclose: () => void;
}
let { open, onclose }: Props = $props();
let dialog: HTMLDialogElement;
$effect(() => {
if (open) {
dialog.showModal();
} else {
dialog.close();
}
});
function handleClick(e: MouseEvent) {
if (e.target === dialog) onclose();
}
</script>
<dialog
bind:this={dialog}
onclick={handleClick}
{onclose}
class="inset-0 m-auto rounded-xl bg-white p-6 shadow-2xl backdrop:bg-black/50 backdrop:backdrop-blur-sm"
>
...
</dialog>
ポイントは以下です。
-
showModal()で開いた<dialog>は 最上位レイヤーに配置される - 最上位レイヤーはすべてのスタッキングコンテキストの上に存在するため、
z-indexを指定しなくても必ず最前面に表示される
command 属性や commandfor 属性を使うとJavaScriptを使わなくてもモーダルウィンドウを制御できます。ただしChrome 135, Safari 26.2, Firefox 144以降と比較的新しめのブラウザが必要なのでご注意ください。
まとめ
今回のデモで使った z-index の運用上のパターンは、1, auto, 限定的に-1という3種類だけでした。
-
isolation: isolateで各ブロックを独立したスタッキングコンテキストに閉じ込めることで、z-indexの値はほぼboolean的な使い分けで足りる- 意図的に背面に置きたい要素だけ -1 を使用する
- 同じスタッキングコンテキスト内で同じ
z-indexの要素が重なる場合はDOM順で解決できる - モーダルウィンドウのような「必ず最前面」の要件は
<dialog>+showModal()の 最上位レイヤーに任せることで、z-indexを使わずに実現できる
z-index の管理で悩んでいる方の参考になれば嬉しいです。