【React / Next.js】createPortalでmodalコンポーネントを作る
やりたいこと
モーダルは画面最前面に表示させることの多い性質上、body
タグ直下にレンダリングしたいことがよくある。
そこで親コンポーネントより外にDOMをレンダリングできるReact Portalを使い、モーダルコンポーネントを実装してみる。
実装方法
1. モーダルの雛形を作る
import { type ReactNode } from "react";
const Modal = ({ children }: { children: ReactNode }) => { return ( <div> <div> <button type="button" aria-label="モーダルを閉じる"> × </button> {children} </div> </div> );};
export default Modal;
まずはprops
で渡されたchildren
と、閉じるボタンを描画する簡素なコンポーネントを作る。
import { type ReactNode } from "react";import { type ReactNode, useState } from "react";
const Modal = ({ children, buttonText, canCloseByClickingBackground = true,}: { children: ReactNode; buttonText: string; canCloseByClickingBackground?: boolean;}) => { const [isOpened, setIsOpened] = useState(false);
const open = () => setIsOpened(true); const close = () => setIsOpened(false);
if (!isOpened) { return ( <button type="button" onClick={open}> {buttonText} </button> ); }
return ( <div> <div> <button type="button" aria-label="モーダルを閉じる" onClick={close} > × </button> {children} </div> {canCloseByClickingBackground && <div onClick={close} />} </div> );};
export default Modal;
- ボタンをクリックするとモーダルが開く
- モーダル内のxボタンをクリックするとモーダルが閉じる
上記要件を満たすように追記。
canCloseByClickingBackground
がtrue
の時は、背景をクリックしてもモーダルが閉じるようにする。
2. createPortalでレンダリング
import { type ReactNode, useState } from "react";import { createPortal } from "react-dom";
return (const elmModal = ( <div> <div> <button type="button" aria-label="モーダルを閉じる" onClick={close} > × </button> {children} </div> {canCloseByClickingBackground && <div onClick={close} />} </div>);
return createPortal(elmModal, document.body);};
export default Modal;
createPortal
を使い、body
タグ直下にモーダルをレンダリングさせる。
第1引数にはレンダリングしたい要素を、第2引数にはレンダリング先のノードを指定。
3. スタイルの設定
.wrapper { position: fixed; top: 0; left: 0; z-index: 2; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; contain: content;}
.content { position: relative; z-index: 1; box-sizing: border-box; width: 70vw; max-width: 700px; max-height: 90vh; padding: 40px; overflow-y: auto; background-color: #fff; border-radius: 5px; animation: anim-modal 0.5s ease; will-change: transform, opacity;}
@keyframes anim-modal { from { opacity: 0; transform: translateY(15px); }}
.btnClose { position: absolute; top: 5px; right: 10px; padding: 0; font-size: 2.7rem; font-weight: bold; line-height: 1; color: #bbb; cursor: pointer; background-color: transparent; border: none;}
.background { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0 0 0 / 50%);}
@media screen and (max-width: 768px) { .content { width: 96vw; padding: 30px 25px; }}
あとはモーダルっぽいUIにしたいので、modal.module.css
を作成。
import { type ReactNode, useState } from "react";import { createPortal } from "react-dom";import styles from "../styles/modal.module.css";
const elmModal = ( <div> <div className={styles.wrapper}> <div> <div className={styles.content}> <button className={styles.btnClose} type="button" aria-label="モーダルを閉じる" onClick={close} > × </button> {children} </div> {canCloseByClickingBackground && <div onClick={close} />} {canCloseByClickingBackground && ( <div className={styles.background} onClick={close} /> )} </div> );
return createPortal(elmModal, document.body);};
export default Modal;
4. コンポーネントを呼び出す
import Modal from "./modal";
const SomeComponent = () => { return ( <Modal buttonText="モーダルを開く"> <div> <p>モーダル内のコンテンツです。</p> <p>モーダル内のコンテンツです。</p> <p>モーダル内のコンテンツです。</p> <p>モーダル内のコンテンツです。</p> <p>モーダル内のコンテンツです。</p> <p>モーダル内のコンテンツです。</p> <p>モーダル内のコンテンツです。</p> </div> </Modal> );};
export default SomeComponent;
あとは今回実装したコンポーネントを呼び出す。モーダル内に表示したい要素をchildren
として渡せばOK。
無事モーダルが表示され、
body
タグ直下の最後尾にレンダリングされていた 🎉
5. テスト実装
<div className={styles.wrapper}><div className={styles.wrapper} data-testid="modal-wrapper">
import Modal from "./modal";import { render, screen } from "@testing-library/react";import userEvent from "@testing-library/user-event";
const DUMMY_MODAL_TEXT = "ダミーのテキストです。";const DUMMY_BUTTON_TEXT = "ダミーのボタンです";
describe("Modalコンポーネント", () => { test("「開く」ボタンをクリックするとモーダルが開く", async () => { render( <Modal buttonText={DUMMY_BUTTON_TEXT}> <p>{DUMMY_MODAL_TEXT}</p> </Modal> ); userEvent.click(screen.getByText(DUMMY_BUTTON_TEXT));
expect(await screen.findByTestId("modal-wrapper")).toBeInTheDocument(); expect(await screen.findByText(DUMMY_MODAL_TEXT)).toBeInTheDocument(); });
test("xボタンをクリックするとモーダルが閉じる", async () => { render( <Modal buttonText={DUMMY_BUTTON_TEXT}> <p>{DUMMY_MODAL_TEXT}</p> </Modal> ); userEvent.click(screen.getByText(DUMMY_BUTTON_TEXT)); userEvent.click(await screen.findByText("×"));
expect(await screen.findByTestId("modal-wrapper")).not.toBeInTheDocument(); });});
最低限のテストも実装し、無事通った。