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