z-index の管理規約 - スケール定義と衝突防止

z-index は CSS の中でも特にカオスになりやすいプロパティです。「重なりが崩れたからとりあえず 9999 にしよう」という対処を繰り返した結果、z-index: 99999 と z-index: 999999 が競い合うプロジェクトは珍しくありません。この問題の根本原因は、z-index に規約がないことにあります。

z-index が混乱する仕組み

z-index は単独では機能しません。positionstatic 以外(relative, absolute, fixed, sticky)に設定された要素に対してのみ効果を持ちます。さらに、z-index を指定した要素は新しいスタッキングコンテキスト(重ね合わせの文脈)を生成する場合があり、この仕組みを理解していないと意図しない重なり順になります。

/* .modal の z-index が 100 でも、
   親要素の z-index が 1 なら
   z-index: 2 の要素より下に表示される */
.parent {
  position: relative;
  z-index: 1;
}

.parent .modal {
  position: fixed;
  z-index: 100; /* 親のコンテキスト内でしか効かない */
}

.sibling {
  position: relative;
  z-index: 2; /* .parent より上 → .modal より上 */
}

この例のように、子要素にどれだけ大きな z-index を設定しても、親のスタッキングコンテキストに閉じ込められるため、親の兄弟要素を超えることはできません。z-index の値を闇雲に上げても解決しない問題が存在するのは、このスタッキングコンテキストの仕組みがあるからです。

スケールを定義する

z-index の管理で最も効果的な方法は、プロジェクト全体で使う値のスケールをあらかじめ定義しておくことです。

:root {
  --z-base: 0;
  --z-dropdown: 100;
  --z-sticky: 200;
  --z-overlay: 300;
  --z-modal: 400;
  --z-toast: 500;
}

このように CSS カスタムプロパティで一元管理することで、各 UI 要素がどの「層」に属するかが明確になります。実際のコンポーネントではこれらの変数を参照して z-index を指定します。

.dropdown-menu {
  position: absolute;
  z-index: var(--z-dropdown);
}

.modal-backdrop {
  position: fixed;
  z-index: var(--z-overlay);
}

.modal-content {
  position: fixed;
  z-index: var(--z-modal);
}

.toast {
  position: fixed;
  z-index: var(--z-toast);
}

直接数値を書くのではなく変数を経由することで、「なぜこの値なのか」という意図がコードから読み取れるようになります。

100 刻みにする理由

スケールの値を 1, 2, 3 ではなく 100, 200, 300 のように間隔を空けるのには理由があります。同じ層の中で微調整が必要になったとき、間の値を使えるからです。

ドロップダウン層の例

ドロップダウン本体が --z-dropdown(100)、ドロップダウン内のツールチップが 150 というように、層の中で細かく順序を制御できる。

追加コンポーネントへの対応

プロジェクトの途中で新しい UI パターンが増えた場合でも、既存の値を変更せずに間に挿入できる。

ただし、同一層内で使う中間値が増えすぎた場合は、そもそもスケールの設計を見直す必要があるサインです。

スタッキングコンテキストの生成を意識する

z-index の問題を防ぐためには、どの要素が新しいスタッキングコンテキストを生成するかを把握しておく必要があります。z-index 以外にも、以下のプロパティがスタッキングコンテキストを生成します。

opacity が 1 未満の要素
transform が none 以外の要素
filter が none 以外の要素
will-change に z-index や transform を指定した要素
position: fixed または sticky の要素

たとえば、カードコンポーネントに transform: translateY(-2px) のようなホバーエフェクトを付けると、そのカードが新しいスタッキングコンテキストを生成します。カード内のドロップダウンがカードの外にはみ出して表示されるべき場面で、意図せずクリッピングされることがあるのはこれが原因です。

/* transform によって新しいスタッキングコンテキストが生まれる */
.card:hover {
  transform: translateY(-2px);
  /* この瞬間、.card 内の z-index は
     外側の要素と独立した文脈になる */
}

isolation プロパティで意図的にコンテキストを作る

スタッキングコンテキストを意図的に作りたい場合は、isolation: isolate を使います。このプロパティは副作用なく新しいスタッキングコンテキストを生成できるため、コンポーネントの z-index が外部に漏れることを防げます。

.card-list {
  isolation: isolate;
}

.card-list .card {
  position: relative;
  z-index: 1;
}

.card-list .card:hover {
  z-index: 2; /* 同じリスト内の他のカードより上に来る */
}

isolation: isolate を親に設定しておけば、子要素の z-index がどんな値であっても、その親の外側には影響しません。コンポーネント設計において z-index のスコープを限定する手段として非常に有効です。

isolation なし

コンポーネント内の z-index がグローバルに影響し、他のコンポーネントと衝突するリスクがある。

isolation: isolate あり

コンポーネント内の z-index がローカルスコープに閉じ込められ、外部への影響を遮断できる。

規約としてまとめる

z-index の管理規約を策定する際は、以下の点を明文化しておくと衝突を未然に防げます。

z-index の直接的な数値指定を禁止し、CSS カスタムプロパティ経由で指定する
スケールは 100 刻みとし、用途ごとの層を定義する
コンポーネント内で z-index を使う場合は親要素に isolation: isolate を検討する
z-index を追加・変更する際はスケール定義ファイルを確認する

z-index の問題は、値を大きくすれば解決するものではありません。スタッキングコンテキストの仕組みを理解し、スケールと isolation によってスコープを管理する。この 2 つの軸を押さえることで、z-index の衝突は構造的に防止できます。