createPortalで、モーダルを親要素の外に描画する方法
React の createPortal を使うと、コンポーネントの中で開いたモーダルを、DOM の上では document.body の直下に描画できます。親の要素に付いた CSS で、ダイアログが小さな箱の中に切り取られてしまう問題を避けられます。DOM の場所が変わっても、クリックやコンテキストは React のツリーどおりに親とつながったままです。
筆者は検証環境で、同じダイアログをポータルあり・なしで描画し、DOM の場所、画面での見え方、クリックの伝わり方を比べました。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で動かしました。画面での見え方は、Chromium 141 を Playwright 1.56.1 で操作して確かめました。
createPortal(JSX, document.body) を返すと、その JSX は body の直下に描画されます。親の CSS の影響は受けなくなりますが、クリックは React のツリーをたどって親の onClick まで届きます。届かせたくないときは、ポータルの中で stopPropagation を呼びます。
ダイアログを document.body に描画するコード
まず、ポータルを使わないダイアログの InlineModal を作り、それを createPortal で包んだ PortalModal を作りました。createPortal の1つ目の引数は描画する JSX、2つ目は描画先の DOM の要素です(公式リファレンスの createPortal の引数の説明)。
import { type ReactNode } from 'react';
import { createPortal } from 'react-dom';
type Props = { title: string; onClose: () => void; children: ReactNode };
// 画面の上に重ねて出すダイアログ(ポータルを使わない版)
export function InlineModal({ title, onClose, children }: Props) {
return (
<div className="backdrop">
<div className="modal" role="dialog" aria-modal="true" aria-label={title}>
<h3>{title}</h3>
{children}
<button className="close" onClick={onClose}>閉じる</button>
</div>
</div>
);
}
// 同じダイアログを、createPortal で document.body の中に描画する版
export function PortalModal(props: Props) {
return createPortal(<InlineModal {...props} />, document.body);
}
ダイアログを開くボタンは、overflow: hidden の付いたカードの中に置きました。usePortal で、どちらのダイアログを使うかを切り替えます。
export function Card({ usePortal }: { usePortal: boolean }) {
const [open, setOpen] = useState(false);
const Modal = usePortal ? PortalModal : InlineModal;
return (
<div className="card">
<p>{usePortal ? 'createPortal あり' : 'createPortal なし'}</p>
<button className="open" onClick={() => setOpen(true)}>ダイアログを開く</button>
{open && (
<Modal title="確認" onClose={() => setOpen(false)}>
<p>この内容で保存しますか?</p>
</Modal>
)}
</div>
);
}
ダイアログが DOM のどこに入るか
検証環境(jsdom)で、2つの版のカードを描画し、「ダイアログを開く」と「閉じる」を順に押しました。ダイアログの外側の要素(.backdrop)の親と、body の直下にある要素を記録しています。
=== createPortal なし
開いたあと: .backdrop の親=div.card / .card の中か=true
body の子要素: div#root
閉じたあと: ダイアログなし
=== createPortal あり
開いたあと: .backdrop の親=body / .card の中か=false
body の子要素: div#root, div.backdrop
閉じたあと: ダイアログなし
ポータルなしでは、ダイアログはカードの div の中に入りました。ポータルありでは、body の直下、#root と並ぶ位置に入りました。どちらの版も、閉じたときに(open が false になって描画されなくなったときに)ダイアログの要素は DOM から消えました。body に描画したぶんを自分で片づける必要はありません。
親に transform があると、ポータルなしのダイアログは切れる
DOM の場所の違いが画面にどう出るかを、Chromium で確かめました。ダイアログは position: fixed で画面の中央に置き、背景の暗い幕(.backdrop)も position: fixed で画面全体に広げています。カードには、どちらの場合も overflow: hidden を付けました。
overflow だけ / card-inline: {"modal":"300x128 @(90,96)","backdrop":"480x320 @(0,0)","centerIsModal":true}
overflow だけ / card-portal: {"modal":"300x128 @(90,96)","backdrop":"480x320 @(0,0)","centerIsModal":true}
overflow + transform / card-inline: {"modal":"300x128 @(90,42)","backdrop":"444x92 @(18,60)","centerIsModal":false}
overflow + transform / card-portal: {"modal":"300x128 @(90,96)","backdrop":"480x320 @(0,0)","centerIsModal":true}
modal と backdrop は要素の大きさと位置、centerIsModal は画面の中央の点にダイアログがあるかどうかです。カードに overflow: hidden を付けただけでは、ポータルなしでも幕は画面全体(480×320)に広がり、ダイアログは中央に出ました。カードに transform: translateX(0) を足すと、ポータルなしの幕はカードと同じ大きさ(444×92)に縮み、画面の中央にダイアログはなくなりました。ポータルありの版は、どちらの CSS でも変わりませんでした。

MDN の position のページにある、固定配置の説明によると、position: fixed の要素は、ふだんは画面(ビューポート)を基準に置かれます。ただし、祖先の要素に transform、perspective、filter のどれかが付いていると、その祖先が基準になります。ポータルなしのダイアログは、カードを基準に置かれ、カードの overflow: hidden で切り取られました。
公式リファレンスのモーダルの例でも、ダイアログは DOM の上で親の要素の外に出るので、overflow: hidden などダイアログの邪魔をする CSS が親にあっても影響を受けないと説明されています。なお、公式の例のダイアログは position: absolute で置かれています。position: absolute のダイアログは、この記事では試していません。
クリックは React のツリーをたどって親に届く
ポータルで DOM の場所が変わっても、イベントは React のツリーどおりに伝わります。公式リファレンスの createPortal の注意点には、ポータルの中をクリックすると、ポータルを囲む <div onClick> の onClick が呼ばれると書かれています。
これを確かめるため、onClick を付けた親の div の中で、ボタンをポータルに描画しました。比べるために、同じ div に DOM の addEventListener でもクリックを受け取るようにしています。親はコンテキストに dark を入れ、ボタンはそれを読んで記録します。
const logs: string[] = [];
const Theme = createContext('light');
// ポータルの中のボタン。stop が true なら、クリックの伝わりをここで止める
function PortalButton({ stop }: { stop: boolean }) {
const theme = useContext(Theme);
return createPortal(
<div className="modal">
<button
className="inner"
onClick={(e) => {
logs.push(`ポータルの中のボタン onClick(テーマ=${theme})`);
if (stop) e.stopPropagation();
}}
>
OK
</button>
</div>,
document.body,
);
}
function Parent({ stop }: { stop: boolean }) {
const divRef = useRef<HTMLDivElement>(null);
const [clicks, setClicks] = useState(0);
useEffect(() => {
// 比べるために、同じ div に DOM の addEventListener でもクリックを待つ
const div = divRef.current!;
const onNative = () => logs.push('親の div の addEventListener');
div.addEventListener('click', onNative);
return () => div.removeEventListener('click', onNative);
}, []);
return (
<Theme value="dark">
<div className="parent" ref={divRef} onClick={() => { logs.push('親の div の onClick'); setClicks((c) => c + 1); }}>
<p className="clicks">親が受け取ったクリック:{clicks}回</p>
<PortalButton stop={stop} />
</div>
</Theme>
);
}
ボタンを1回押した結果です。stopPropagation を呼ばない場合と呼ぶ場合を、それぞれ試しました。
=== stopPropagation なし
ボタンは .parent の中か: false
ポータルの中のボタン onClick(テーマ=dark)
親の div の onClick
親の div の addEventListener が呼ばれた回数: 0
親が受け取ったクリック:1回
=== stopPropagation あり
ボタンは .parent の中か: false
ポータルの中のボタン onClick(テーマ=dark)
親の div の addEventListener が呼ばれた回数: 0
親が受け取ったクリック:0回
DOM の上では、ボタンは親の div の中にありません。それでも、React の onClick には届きました。DOM の addEventListener は、一度も呼ばれませんでした。DOM の上のクリックは、ボタンから body、document へと伝わるだけで、親の div を通らないからです。ポータルの中で stopPropagation を呼ぶと、React の onClick にも届かなくなりました。
Chromium で同じ動きを見たのが、次の GIF です。この画面では、ダイアログの「OK」は何もせず、「閉じる」だけが stopPropagation を呼んでからダイアログを閉じます。

親の onClick に処理を書いていると、ダイアログの中のクリックでも、その処理が動きます。公式リファレンスの注意点では、それで困るときはポータルの中で伝わりを止めるか、ポータルを React のツリーの上のほうへ移すように書かれています。
コンテキストもポータルの中に届く
上の結果の「テーマ=dark」のとおり、ポータルの中のボタンは、親が入れたコンテキストの値を読めました。公式リファレンスの createPortal の説明にも、ポータルが変えるのは DOM の場所だけで、ほかの点では、描画したコンポーネントの子として動くと書かれています。テーマやログイン中のユーザーをコンテキストで渡していても、ダイアログの中で同じように使えます。
描画先の要素は先に用意しておく
document.body ではなく、HTML に用意した <div id="modal-root"> のような要素に描画することもできます。この場合、描画先の要素は、createPortal を呼ぶ時点で DOM にある必要があります(公式リファレンスの引数の説明)。要素がないまま document.getElementById('modal-root') の結果を渡すと、検証環境では次のようになりました。
#modal-root があるか: false
エラー: Target container is not a DOM element.
#modal-root を作ったあと: true
#modal-root の中身: <p>ダイアログ</p>
要素がないときは getElementById が null を返し、描画のときにエラーになりました。要素を作ってから描画し直すと、その中に描画されました。TypeScript では ! を付けると型チェックは通りますが、実行時のエラーは防げません。
createPortal が変えるのは描画の場所だけです。ダイアログを開いたときにフォーカスを移す、Esc キーで閉じる、ダイアログの外を操作できなくする、といった処理は、別に作る必要があります。公式リファレンスの注意書きでは、WAI-ARIA のモーダルダイアログのパターンに従うよう勧めています。この記事では、これらの処理は試していません。





