Portalを内部で利用するコンポーネントにおけるz-indexの考え方
フロントエンドを担当している三谷です。
TAIANでは多くのプロダクトでRadix UIを利用していますが、画面全面に表示する必要のあるDialogやDropdown Menuの内部で、Portal
というコンポーネントが利用されています。
ほかの有名どころのUIコンポーネントライブラリでも、Portal
が提供されています。
Portal
はReactのcreatePortal
というAPIを使いやすいようにラップしたコンポーネントですが、createPortal
自体、普段Reactで開発する中であまり馴染みのないAPIだと思います。
しかし、その性質をわかっていない状態で「とりあえず重なり要素だからz-index
をつけておけば間違いないかな」などと考えたり、いたずらにz-index
をつけがちなAIによる実装(自分の環境だけ?)をそのまま受け入れるとつらくなってきそうなため、
-
createPortal
とはなにか -
z-index
をコンポーネントにつけるべきか?
について自分なりの考えをまとめてみました。
(本記事は最新バージョンのRadix Primitivesコンポーネントを利用することを想定しています。)
想定読者
- UIコンポーネントライブラリを用いて汎用コンポーネントを作成・メンテナンスする機会がある方
-
Portal
(createPortal
)をあまり意識して利用してこなかった方
createPortal
とは
そもそも一言でいうと、「コンポーネントを、DOMツリー上の任意の場所に移動させる機能」です。
通常、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要素の子要素として配置されます。
単体で扱うことは多くないかもしれませんがコンポーネント内部で内部用の類似したコンポーネントが使われている事が多いです。
例えば以下はDialogコンポーネントですが、Dialog.Portal
(内部的にはPortalPrimitive)が、ラップした2つのコンポーネント(Overlay
とContent
)をcreatePortal
でdocument.body
配下にうつすため、レンダリングされるとdocument.body
配下に2つdiv要素が末尾に追加されます。
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>
);
Portal
は他にも前面の表示が必要となるSelect
やToolTip
などにも利用されています。
Portalを用いるコンポーネントにはz-indexをつけないほうがシンプルになる
ここまで書くとわかるかと思いますが、Overlay
やModal
が最前面に表示されているのは内部で大きな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番目のダイアログ-コンテンツ
- セレクト内のコンテンツ
うまくいかない例
先ほどのレイアウトを実現しようとしたときに、Overlay
とDialog
があったらなんとなく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
と比較されてしまうからです。
root
やroot
に近い要素ではスタッキングコンテキストを生成して子要素がz-index
をあてても影響を受けないようにしましょう。
スタッキングコンテキストについては以下の記事等でわかりやすく説明されています。
z-index:0
やposition:sticky
などもスタッキングコンテキストを生成しますが、あくまでもスタッキングコンテキストを生成することだけを明示的に示すのであれば、 isolation: isolate;
を設定するのがよいかと思います。
ちなみに、各コンポーネントも同様にスタッキングコンテキストを生成する必要がありますが、たいていは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では、このような開発・技術・思想に向き合い、未来をつくる仲間を一人でも多く探しています。少しでも興味を持っていただいた方は弊社の紹介ページをご覧ください。
Discussion