概要
createPortal を使うと、React ツリー上では子コンポーネントだが、DOM 上では別の要素(通常 body 直下)に描画できる。
overflow: hidden や z-index の影響を受けずにモーダル・トーストを表示したいときに使う。
インストール
# 追加インストールは不要
モーダルの実装例
// src/components/Modal.tsx
"use client";
import { createPortal } from "react-dom";
import { useEffect, useState } from "react";
type Props = {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
};
export function Modal({ isOpen, onClose, children }: Props) {
const [mounted, setMounted] = useState(false);
// SSR では document が存在しないため、mount 後にのみ Portal を描画する
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!mounted || !isOpen) return null;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
>
{/* オーバーレイ */}
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
{/* コンテンツ */}
<div className="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<button
onClick={onClose}
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600"
aria-label="閉じる"
>
✕
</button>
{children}
</div>
</div>,
document.body
);
}
// src/app/page.tsx
"use client";
import { useState } from "react";
import { Modal } from "@/components/Modal";
export default function HomePage() {
const [isOpen, setIsOpen] = useState(false);
return (
<main className="p-6">
<button
onClick={() => setIsOpen(true)}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white"
>
モーダルを開く
</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<h2 className="mb-2 text-lg font-bold">タイトル</h2>
<p className="text-sm text-gray-600">モーダルのコンテンツをここに書く。</p>
</Modal>
</main>
);
}
ポイント
createPortal(children, container)の第2引数にレンダリング先の DOM 要素を渡す- SSR 対応のため
useEffectでmountedフラグを管理し、クライアントのみで Portal を描画する - イベント(
onClick等)は React ツリーに沿って伝播するため、Portal 先の DOM 階層とは無関係にバブリングする - Radix UI や headlessui が内部で
createPortalを使っているため、外部ライブラリで十分な場合はそちらを使う role="dialog"とaria-modal="true"でアクセシビリティを確保する