🔥

z-index 地獄を避ける方法

に公開2

この記事は MOSH Advent Calendar 2025 の15日目の記事です。

MOSH には Page Builder というノーコードでランディングページを構築できるプロダクトがあります。この記事では Page Builder の開発を通じて私が学んだことの中から、多数の要素の重ね合わせ順序をシンプルに構成する方法を紹介します。[1]


ヘッダと左右のサイドバーは固定して常に最前面に,プレビュー上にはハイライトや編集メニューを配置


要素の重ね合わせはフロントエンド開発でしばしば厄介な問題を引き起こします。重ね合わせ順序を調整する手段として最もよく知られているのは恐らく z-index の値を設定することではないでしょうか。
しかし、その作業は煩雑になりがちです。いくら値を大きくしても上に表示されない、どの要素にどんな値が設定されているのかパッと見よく分からない、適切な値が分からないからとりあえず大きな値にしておく...などなど😢

z-index は比較的単純なものですが、無闇に使うと上記のような状況を引き起こします。ではどうすれば良いのか疑問が湧いてくると思いますが z-index を使っていないときに重ね合わせ順序がどうなるのか、z-index がいつ必要になるのかを理解するとだいぶ見通しが良くなります。

重ね合わせの基本

z-index を指定しない場合、要素は以下の順序で重なります。(先頭が一番上)

  1. 位置指定ありの要素 (positionstatic 以外の要素)
  2. 位置指定なしの要素

以下は position: relative の要素と position 指定無しの要素を重ねた例です。position: relative の要素が上に表示されます。

位置指定ありの要素同士、位置指定なしの要素同士の重ね合わせ順序は DOM の順序と一致します。DOM の順序が後の要素が上に表示されます。

以下は position: relative の要素同士を重ね合わせた例です。
DOM の順序が後の要素が上に表示されます。

z-index は DOM の順序とは異なる順序を定義します。
先ほどの例に z-index の指定を追加して要素の順序を入れ替えてみましょう。
(z-index を指定していない要素は z-index: 0 として扱われます[2])

z-index: 1 の要素が z-index が指定されていない要素の上に表示されました。

また、レイアウトを工夫することで z-index を使った場合と同様の重ね合わせ順序を DOM の順序だけで実現できます。書かれている順序がそのまま重ね合わせの順序になります。

この例の場合 z-index を指定した場合と比べて要素の位置を調整するためのコードが増えてしまいました。あまり実用的ではありませんが、とりあえず「DOM の順序だけで重ね合わせ順序を調整することが可能」だということを納得して頂ければ幸いです😅

Stacking Context

要素の重ね合わせ順序について考慮しなければいけないことがもう一つあります。 Stacking Context です。これは重ね合わせ順序の判定範囲を決めます。

Stacking Context が生成される条件はいくつかありますが例えば position: relative かつ z-index: auto 以外のときや isolation: isolate を指定したときに生成されます。[3]

以下の例では 2 つの Stacking Context にそれぞれ z-index が 1 と 9999 の要素を配置しています。

├── 親A (Stacking Context A)
│   └── 子 (z-index: 9999)
│
└── 親B (Stacking Context B)
    └── 子 (z-index: 1)

重ね合わせ順序の計算は同じコンテキストの中だけで行われます。z-index をいくら大きくしても別のコンテキストにある要素との重ね合わせ順序は変わりません。

上の例の場合、親 A が 親 B の下になるなら、親 A の子の z-index をいくら大きくしても 親 B やその子の上に描画されることはありません。

以下のコードでは container が Stacking Context を生成します。
1つ目の box の z-index は 2つ目の box の z-index よりも大きいですが Stacking Context が別れているためこれらの box 同士の重ね合わせ順序の結果には影響しません。(container の順序が比較されます。各コンテナは z-index が同じであるため DOM 順序に従って重なります)

ポイントをまとめてみましょう。

  1. 重ね合わせ順序は Stacking Context ごとに計算される
  2. Stacking Context 内の重ね合わせ順序の計算は以下の規則に従う
    1. 位置指定ありの要素は位置指定なしの要素の上に配置される
    2. 位置指定あり同士、位置指定なし同士の順序は DOM の順序で決まる (後ろの要素が上に重なる)
    3. z-index によって DOM 順序とは異なる重ね合わせ順序を定義できる

Page Builder の UI 要素の重ね合わせ順序の調整

Page Builder では UI 要素とそれらの重ね合わせ順序を以下のように定義しています。
(一番上に表示するものが最初、一番下に表示するものが最後)

  1. ヘッダー、サイドバー、右パネル
  2. 編集メニュー (上下アイコンやゴミ箱アイコンのところ)
  3. ホバー中の要素のハイライト (プレビューしてるパーツの周りの青い太枠線)
  4. 選択中の要素のハイライト (〃青い枠線)
  5. ホバー中の要素の子要素のハイライト (〃青い破線)
  6. ホバー中の要素の親要素のハイライト (〃青い破線)
  7. プレビュー (ユーザー作成しているページのプレビュー)

上の内容をそのまま z-index を調整したりしながら実装することもできますが Stacking Context を分けることで z-index の使用箇所を減らしたり、重ね合わせについて同時に考える範囲を絞ることができます。そうすることで意図しない重ね合わせ順序にしてしまう可能性や z-index の管理にかかる手間を減らすことができます。

Page Builder では以下の理由で編集メニューとハイライト表示を一つの Stacking Context にまとめることにしました。

  • 編集メニューとハイライト表示はどちらも常にプレビューの上、ヘッダーの下に表示したい
  • 編集メニューハイライトはどちらもプレビューの上に配置するのでまとまっていた方が管理しやすい
  • 直感的 (プレビュー用のレイヤの上にハイライト用のレイヤを重ねるという構造は他の人にとってもイメージしやすい...はず)

Stacking Context を追加した形になおすと以下のようになります。

  1. ヘッダー、サイドバー、右パネル
  2. オーバーレイ表示 (New✨)
    1. 編集メニュー
    2. ホバー中の要素のハイライト
    3. 選択中の要素のハイライト
    4. ホバー中の要素の子要素のハイライト
    5. ホバー中の要素の親要素のハイライト
  3. プレビュー

まだオーバーレイ表示の中が少し複雑に見えるかもしれませんが編集メニュー以外の要素は対話型コンテンツを含まないため DOM 順序だけで重ね合わせ順序を調整できます。

次に実装ですが、まずはヘッダー、サイドバー、右パネル、プレビューを表示する領域を配置します。

function Editor(){
  return (
      <div className="h-screen">
          <Header />
          <div className="flex flex-grow">
            <SideBar className="z-10" />
            <PreviewArea />
            <RightPanel />
          </div>
      </div>
  );
}

サイドバーとプレビューの重ね合わせ順序が DOM 順序と合わないので z-index を指定しました。サイドバーには対話型コンテンツが含まれているので DOM 順序を変更する方法は使いませんでした。

プレビューとオーバーレイ表示は isolation: isolate でそれぞれ Stacking Context を生成しました。

function PreviewArea(){
  return (
    <div className="relative">
      <Preview />
      <Overlays />
    </div>
}

function Preview(){
  return (
    <div className="isolate">
      {/* プレビュー */}
    </div>
  )

function Overlays(){
  return (
    <div className="absolute inset-0 isolate">
      {/* 省略 */}
    </div>
  )
}

オーバーレイ表示の Stacking Context の中では各ハイライトと編集メニューをそれぞれ position: absolute で配置し、重ね合わせは DOM の順序で調整しました。

function Overlays(){
  // ...

  // { top: number; left: number; width: number; height: number }
  const { elementRects, setElementRects } = useState({});
  const { selected, hovered, hoveredParent, hoveredChildren } = elementRects;

  useEffect(()=>{
    // プレビュー中のパーツの表示位置とハイライトの位置を同期する(この次で説明)
  }, [...])

  return (
    <div className="absolute inset-0 isolate">
      {/* ハイライト表示 */}
      <div className="absolute" style={hoveredParent}>
        <Highlight />
      </div>
      <div className="absolute" style={hoveredChildren}>
        <Highlight />
      </div>
      <div className="absolute" style={selected}>
        <Highlight />
      </div>
      <div className="absolute" style={hovered}>
        <Highlight />
      </div>

      {/* 編集メニュー */}
      <div className="absolute" style={selected}>
        <EditMenu />
      </div>
    </div>
  )
}

表示位置の同期

さて、重ね合わせ順序の調整は以上なのですが Page Builder ではもう一つ工夫が必要でした。

ハイライトの位置はプレビューしている要素の位置と一致している必要があります。
(無関係な場所をハイライトしても意味が無い😅)

これには課題が二つありました。

一つ目はプレビューしている要素の位置とサイズの取得です。幸いプレビューしている各要素には DOM ツリー上での id が設定されていました。なので id と DOM API を使って位置とサイズを取得できました。

もう一つは要素の位置やサイズの変化に追従することでした。プレビュー領域の DOM 要素の位置やサイズは以下のような理由で変更される可能性がありました。

  1. 編集メニューや右パネルの操作
  2. プレビュー領域のスクロール
  3. ハイライト中の要素以外の変化に伴うリサイズ
    • デスクトップ/モバイルのプレビューの切り替え
    • 外部リソースの読み込み状況の変化 (画像や動画など要素の場合のみ)
    • その他UI操作に伴う変化色々😓

これらの理由ごとに対処方法を変えました。

一つ目の「編集メニューや右パネルの操作」で変化した場合に関してはデータを保持している React コンテキストが更新されたタイミングで、要素の位置を取得し直すようにして対応しました。

次に二つ目の「プレビュー領域のスクロール」に対する追従ですが、プレビューのコンポーネントの中でスクロールしていたところをオーバーレイとプレビューの共通の親をスクロールさせる形に変えることで対応しました。

UI上ではプレビューがスクロールしているように見えますが実際にはプレビューの親がスクロールします。


function PreviewArea(){
  return (
+    <div className="overflow-auto" >
       <div className="relative">
         <Preview />
         <Overlays />
       </div>
+    </div>
}

function Preview(){
  return (
    <div className="isolate">
      {/* プレビュー */}
    </div>
  )

function Overlays(){
  return (
    <div className="absolute inset-0 isolate">
      {/* 省略 */}
    </div>
  )
}

三つ目の「ハイライト中の要素以外の変化に伴うリサイズ」に関しては ResizeObserver を使って変更を検知するようにしました。

選択中の要素を監視対象にして、リサイズ監視をオーバーレイ用のコンポーネントに追加しました。ホバー中の要素など、その他の要素のリサイズは実用上ほぼあり得ないと判断したので監視対象から外しました。万が一多少ズレたとしても実用上の問題は無かったので実装のシンプルさを優先しました。


function Overlays() {
  // 省略

+  useEffect(() => {
+    if (!selected) {
+      return;
+    }
+    const element = document.getElementById(selected.id);
+    if (!element) {
+      return;
+    }
+    const observer = new ResizeObserver(() => updateElementRects());
+    observer.observe(element);
+    return () => observer.disconnect();
+  }, [selected, updateElementRects]);

  return (
    <div className="absolute inset-0 isolate">
      {/* 省略 */}
    </div>
  )
}

これで表示位置の同期の問題も解決です🎉


重ね合わせの基本と Page Builder 開発での応用の紹介は以上です。
何か参考になるものがありましたら嬉しいです😄

まとめ

  • レイアウト方法(位置指定 or 位置指定以外)ごとに重ね合わせ順序が決まっている
  • レイアウト方法が同じ場合は DOM 順序が後の要素が上に表示される
  • z-index を使うと重ね合わせ順序をカスタムできる
  • 重ね合わせ順序は Stacking Context ごとに計算される
  • Stacking Context を分けることで重ね合わせ順序の構造を分かりやすくできる
脚注
  1. Page Builder では React と Tailwind CSS を使用しています ↩︎

  2. z-index の既定値は auto です。z-index: auto は Stacking Context を生成しないという点で z-index: 0 と異なります。Stacking Context についてはこのあと説明します ↩︎

  3. ここでは主要なもののみを挙げましたが、他にも「transformnone 以外」 「opacity が 1 未満」など様々な条件があります。詳細は MDN などを参照して下さい ↩︎

MOSH

Discussion

junerjuner

並び替えなら position absolute よりも最近だと flex で order 付けるとか grid で grid-column / grid-row (※こっちも実は order でもよい) 付けるとかもで良さそうですね。