💤

Portalを内部で利用するコンポーネントにおけるz-indexの考え方

に公開

フロントエンドを担当している三谷です。

TAIANでは多くのプロダクトでRadix UIを利用していますが、画面全面に表示する必要のあるDialogやDropdown Menuの内部で、Portalというコンポーネントが利用されています。

ほかの有名どころのUIコンポーネントライブラリでも、Portalが提供されています。

https://mui.com/material-ui/react-portal/
https://chakra-ui.com/docs/components/portal
https://mantine.dev/core/portal/

PortalはReactのcreatePortalというAPIを使いやすいようにラップしたコンポーネントですが、createPortal自体、普段Reactで開発する中であまり馴染みのないAPIだと思います。

しかし、その性質をわかっていない状態で「とりあえず重なり要素だからz-indexをつけておけば間違いないかな」などと考えたり、いたずらにz-indexをつけがちなAIによる実装(自分の環境だけ?)をそのまま受け入れるとつらくなってきそうなため、

  • createPortalとはなにか
  • z-indexをコンポーネントにつけるべきか?

について自分なりの考えをまとめてみました。
(本記事は最新バージョンのRadix Primitivesコンポーネントを利用することを想定しています。)

想定読者

  • UIコンポーネントライブラリを用いて汎用コンポーネントを作成・メンテナンスする機会がある方
  • PortalcreatePortal)をあまり意識して利用してこなかった方

そもそもcreatePortalとは

一言でいうと、「コンポーネントを、DOMツリー上の任意の場所に移動させる機能」です。

https://ja.react.dev/reference/react-dom/createPortal

通常、Reactコンポーネントは記述された通りの親子関係でDOMにレンダリングされます。しかしcreatePortalを使うと、例えばコンポーネントツリーの深い階層にあるモーダルダイアログを、DOM構造的には<body>タグの直下など、全く別の場所にレンダリングできるのです。

import { useState } from "react";
import { createPortal } from "react-dom";
import styles from "./index.module.scss";

export const Home = () => {
  const [showModal, setShowModal] = useState(false);

  return (
    <div className={styles.container}>
      <p className={styles.description}>
        これはReact Portalのサンプルページです。
      </p>
      <button
        type="button"
        className={styles.button}
        onClick={() => setShowModal(true)}
      >
        モーダルを開く
      </button>

      {showModal &&
        createPortal(
          <div className={styles.modalContent}>
            <h2 className={styles.modalTitle}>モーダル</h2>
            <p className={styles.modalText}>
              これはcreatePortalで作成されたモーダルです。
            </p>
            <button
              type="button"
              className={styles.closeButton}
              onClick={() => setShowModal(false)}
            >
              閉じる
            </button>
          </div>,
          document.body,
        )}
    </div>
  );
};

DevToolsをみてもらうと、document.body配下に、rootの兄弟要素としてModalの役割を持つdiv要素が配置されているのがわかります。

これにより、親要素のoverflow: hiddenなどののスタイリングの影響を受けずに、常に最前面に要素を表示させることができます。

DOMの構造は変わっても、Reactコンポーネントとしての親子関係は維持されるため、モーダル内で発生したイベント(クリックなど)は、通常通りReactツリーを遡って親コンポーネントに伝播(バブリング)していきます。

Radix UI内部でどう使われているか

先述したようにRadixではPortalというコンポーネントが存在しています。
先ほど第二引数にDOM要素を渡していましたが、Portalではcontainerというpropsでレンダリング先のDOM要素を決めることができます。デフォルトはdocument.bodyです。

ラップしたReactNodeがcontainerで指定したDOM要素の子要素として配置されます。

https://www.radix-ui.com/primitives/docs/utilities/portal

単体で扱うことは多くないかもしれませんがコンポーネント内部で内部用の類似したコンポーネントが使われている事が多いです。
例えば以下はDialogコンポーネントですが、Dialog.Portal(内部的にはPortalPrimitive)が、ラップした2つのコンポーネント(OverlayContent)をcreatePortaldocument.body配下にうつすため、レンダリングされるとdocument.body配下に2つdiv要素が末尾に追加されます。

https://github.com/radix-ui/primitives/blob/13e76f08f7afdea623aebfd3c55a7e41ae8d8078/packages/react/dialog/src/dialog.tsx#L145-L159

import * as Dialog from "@radix-ui/react-dialog";

export default () => (
  <Dialog.Root>
    <Dialog.Trigger asChild>
     <button>モーダルを開く</button>
    </Dialog.Trigger>
    <Dialog.Portal> 
      <Dialog.Overlay /> <-- bodyの子要素としてレンダリング 
      <Dialog.Content>.  <-- bodyの子要素としてレンダリング-- | 
        <Dialog.Title>タイトル</Dialog.Title>              |
        <Dialog.Description>説明</Dialog.Description>.     |
        <Dialog.Close>閉じる</Dialog.Close>                |
      </Dialog.Content>. <-------------------------------
    </Dialog.Portal>
  </Dialog.Root>
);

https://www.radix-ui.com/primitives/docs/components/dialog

Portalは他にも前面の表示が必要となるSelectToolTipなどにも利用されています。

Portalを用いるコンポーネントにはz-indexをつけないほうがシンプルになる

ここまで書くとわかるかと思いますが、OverlayModalが最前面に表示されているのは内部で大きなz-indexを当てているわけではなく、createPortalを使うことで親要素のスタイルが干渉することがないからです。

そのおかげで、以下のようにDialogのうえにDialogを表示するとき(デザイン的によいかどうかはさておき)やDialog上でSlectを利用する場合も、干渉することなく開いた順番に前面に表示されます。

コード例
import React, { useState } from 'react';
import { Dialog, Select } from 'radix-ui';

const SimpleNestedDialog = () => {
  const [firstDialogOpen, setFirstDialogOpen] = useState(false);
  const [secondDialogOpen, setSecondDialogOpen] = useState(false);
  const [selectedValue, setSelectedValue] = useState('apple');

  return (
    <div>
      <button onClick={() => setFirstDialogOpen(true)}>
        最初のDialogを開く
      </button>

      {/* 最初のDialog */}
      <Dialog.Root open={firstDialogOpen} onOpenChange={setFirstDialogOpen}>
        <Dialog.Portal>
          <Dialog.Overlay />
          <Dialog.Content>
            <Dialog.Title>最初のDialog</Dialog.Title>
            <Dialog.Description>
              これは最初のDialogです。2番目のDialogを開くことができます。
            </Dialog.Description>
            
            <button onClick={() => setSecondDialogOpen(true)}>
              2番目のDialogを開く
            </button>
            
            <Dialog.Close asChild>
              <button>閉じる</button>
            </Dialog.Close>
          </Dialog.Content>
        </Dialog.Portal>
      </Dialog.Root>

      {/* 2番目のDialog(ネスト) */}
      <Dialog.Root open={secondDialogOpen} onOpenChange={setSecondDialogOpen}>
        <Dialog.Portal>
          <Dialog.Overlay />
          <Dialog.Content>
            <Dialog.Title>2番目のDialog(ネスト)</Dialog.Title>
            <Dialog.Description>
              このDialogにはSelectコンポーネントが含まれています。
            </Dialog.Description>

            {/* Select コンポーネント */}
            <div>
              <label>フルーツを選択してください:</label>
              <Select.Root value={selectedValue} onValueChange={setSelectedValue}>
                <Select.Trigger>
                  <Select.Value placeholder="フルーツを選択..." />
                  <Select.Icon></Select.Icon>
                </Select.Trigger>

                <Select.Portal>
                  <Select.Content>
                    <Select.Viewport>
                      <Select.Item value="apple">
                        <Select.ItemText>🍎 りんご</Select.ItemText>
                        <Select.ItemIndicator></Select.ItemIndicator>
                      </Select.Item>
                      <Select.Item value="orange">
                        <Select.ItemText>🍊 オレンジ</Select.ItemText>
                        <Select.ItemIndicator></Select.ItemIndicator>
                      </Select.Item>
                      <Select.Item value="banana">
                        <Select.ItemText>🍌 バナナ</Select.ItemText>
                        <Select.ItemIndicator></Select.ItemIndicator>
                      </Select.Item>
                      <Select.Item value="grape">
                        <Select.ItemText>🍇 ぶどう</Select.ItemText>
                        <Select.ItemIndicator></Select.ItemIndicator>
                      </Select.Item>
                    </Select.Viewport>
                  </Select.Content>
                </Select.Portal>
              </Select.Root>
            </div>

            <p>選択されたフルーツ: <strong>{selectedValue}</strong></p>

            <Dialog.Close asChild>
              <button>キャンセル</button>
            </Dialog.Close>
            
            <button
              onClick={() => {
                alert(`選択されたフルーツ: ${selectedValue}`);
                setSecondDialogOpen(false);
              }}
            >
              確定
            </button>
          </Dialog.Content>
        </Dialog.Portal>
      </Dialog.Root>
    </div>
  );
};

export default SimpleNestedDialog;

body配下に並列で以下の要素がレンダリングされています。

  • rootタグ
  • 最初のダイアログ-オーバーレイ
  • 最初のダイアログ-コンテンツ
  • 2番目のダイアログ-オーバーレイ
  • 2番目のダイアログ-コンテンツ
  • セレクト内のコンテンツ

うまくいかない例

先ほどのレイアウトを実現しようとしたときに、OverlayDialogがあったらなんとなくz-indexを表示される順番に当てておくか?という発想で例えば共通コンポーネントにこのように書いてしまうとしますとこうなります。

<Dialog.Overlay
  style={{
    zIndex: 1,
  }}
/>
<Dialog.Content
  style={{ zIndex: 2 }}
>


Selectのコンテンツも開いているはずなのに隠れている。

z-indexの影響をうけてOverlayは常にDialogの下に表示されてしまいます。
ほかにも、Selectで扱っているPortalはz-index:autoのため、Overlayの下に隠れてしまい、改めてSelectのcontent部分にもz-indexをあてる必要が出てきます。

これらの事象は、z-inexをグローバル変数として管理することで負担を軽減できなくはないですが、毎回共通コンポーネントにz-indexをつける必要が出てきてしまうので、Portalが内部で利用されるコンポーネントには原則z-indexをつけないほうがシンプルに保てます。

スタッキングコンテキストに注意が必要

一方で、Portalを含むコンポーネントにz-indexをつけずに、ルートに近い要素であるヘッダーやサイドバー内の子要素にz-indexをつけるとOverlayよりうえに表示されてしまう場合があります。


ヘッダーの子要素のタイトルにz-index:1をつけるとOverlayよりうえに重なっているようにみえる

今回の場合だとヘッダー、root要素がスタッキングコンテキストを持たないがゆえ、ヘッダーの子要素のz-indexが、rootと兄弟要素のz-indexと比較されてしまうからです。
rootrootに近い要素ではスタッキングコンテキストを生成して子要素がz-indexをあてても影響を受けないようにしましょう。

スタッキングコンテキストについては以下の記事等でわかりやすく説明されています。

https://ics.media/entry/200609/

z-index:0position:stickyなどもスタッキングコンテキストを生成しますが、あくまでもスタッキングコンテキストを生成することだけを明示的に示すのであれば、 isolation: isolate; を設定するのがよいかと思います。

https://developer.mozilla.org/ja/docs/Web/CSS/isolation

ちなみに、各コンポーネントも同様にスタッキングコンテキストを生成する必要がありますが、たいていはRadix UIのサンプルのCSSでも同様のスタイリングがあたっているはずです。


Overlayのposition:fixedをはずすとスタッキングコンテキストが生成されない

まとめ

ここまで説明した通り、Portalを内部利用するコンポーネントを利用する場合は、z-indexは極力減らすことが可能だと考えられます。

一方で、レンダリングされるDOMTreeの場所がすでに決定されていたり、z-indexを内部的に持つサードパーティのコンポーネントがある場合はz-indexによる管理が必要となる場面がありますし、ユーザーフィードバックのためにグローバルに管理されているToastのようなコンポーネントについては、z-indexを用いて最上位に表示する必要が出てきます。

基本的にcreatePortalの性質を理解してz-indexを最小限に抑えながら、必要なコンポーネントに変数管理されたセマンティックなz-inexを付与していくのが、塩梅のとれた設計だと考えます。

We are hiring!

TAIANでは、このような開発・技術・思想に向き合い、未来をつくる仲間を一人でも多く探しています。少しでも興味を持っていただいた方は弊社の紹介ページをご覧ください。

https://taian-inc.notion.site/Entrance-Book-for-Engineer-1829555d9582804cad9ff48ad6cc3605

TAIANテックブログ

Discussion