こんにちは、まちゅです。
今回は、
- 外部モジュールに頼らず
- 背景固定されており
- 最もミニマムな構成で取り回しが良い
モーダルコンポーネントを作っていきます。
成果物はこちら。
(背景と内容はとても適当です。すみません。)
やってはいけないこと
overscroll-behavior: contain;
はモーダル内にスクロール要素が無い場合に機能しないので
利用しないでください。
1pxを要素に追加して疑似的にスクロール連鎖を止める方法は
やめてください。
ガタガタします。
つっかえ棒の要素で固定するなど、
ごり押し(物理)はやめてください。非効率です。
不純物が増え、後々ぐちゃぐちゃになっていきます。
しっかりとcssやブラウザの特性を理解しましょう。
要件定義
今回はモーダルにおける要件を以下に定めます。
-
オーバーレイでページの最も前面に表示される。
-
モーダル内の要素は可変長。
-
childrenとしてモーダルのコンテンツが親から受け渡される。
-
モーダルを閉じると、モーダル内で再生していた動画なども閉じる。
-
背景が固定される。
設計
まずは先ほどの要件の上から4つをさっくりと実装します。
親コンポーネントにbooleanのuseState
を持たせておき、
子の実装でDOMをマウント、アンマウントさせる
がシンプルそうです。
親部分にモーダルの表示非表示を切り替える機構があるのは明確なので、
子が表示状態管理のuseState
を持っていると親に渡す必要があるので面倒です。
親から受け取るようにしましょう。
また、モーダルが非表示の際には要素をアンマウントしておかないと、
モーダル上でYoutube等を再生した際はdisplay:none
では音声が再生され続けてしまうので
display:none
で表示非表示を切り替えないでください。
以下、サンプルの実装とGIFです。
import { ReactNode, type FC, type Dispatch, type SetStateAction } from "react";
interface Props {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
children: ReactNode;
}
export const Modal: FC<Props> = ({ isOpen, setIsOpen, children }) =>
isOpen ? (
<div
style={{
zIndex: 1,
backgroundColor: "rgba(0,0,0,0.8)",
position: "fixed",
cursor: "pointer",
top: "50%",
left: "50%",
color: "white",
transform: "translate(-50%, -50%)",
width: "500px",
maxHeight: "80%",
overflow: "auto",
}}
>
<button
onClick={() => {
setIsOpen(false);
}}
>
モーダル非表示
</button>
<div>{children}</div>
</div>
) : null;
import { type NextPage } from "next";
import { useState } from "react";
import { Modal } from "@components/Modal";
import Image from "next/image";
interface Props {}
const Parent: NextPage<Props> = () => {
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<div style={{ width: "100vw", backgroundColor: "red", padding: "24px" }}>
<button
style={{
cursor: "pointer",
position: "fixed",
}}
onClick={() => {
setIsOpen(true);
}}
>
モーダル表示
</button>
{[...Array(1000)].map(() => (
<div>背面コンテンツ</div>
))}
<Modal isOpen={isOpen} setIsOpen={setIsOpen}>
<Image src="https://onl.tw/Q51Z89J" width={460} height={276} />
<Image src="https://onl.tw/Q51Z89J" width={460} height={276} />
<Image src="https://onl.tw/Q51Z89J" width={460} height={276} />
</Modal>
</div>
);
};
export default Parent;
ここまでは、Reactを学んでいる方であれば楽勝かと思われます。
よく見るとわかりますが、まだ背景が固定されていません。
【超ポイント】背景の固定
結論、
top:XXpx
としてあげれば、fixed要素の位置は固定されます。
これだけです。
-
モーダル表示時
- body要素に
position:fixed
を追加し、topを起点として(現在のスクロール位置*-1)分を上に移動させる。 - モーダル自体もtopを起点として(現在のスクロール位置*-1)分を上に移動させる。
- body要素に
-
モーダル非表示時
- bodyのstyleからtopの値を取得、ユーザーがモーダルを開いた場所に
window.scrollTo
で移動してあげる。
- bodyのstyleからtopの値を取得、ユーザーがモーダルを開いた場所に
です。
position:fixed
をstyleに追加した瞬間にtopのstyleが指定されていないと勝手にデフォルト値の0になり、ページが最上部に戻ってしまいます。
それを防ぐためにtopの値も同時に指定してあげます。
今回はuseEffectでisOpen
の状態を監視してstyleを付け替えましょう。
import {
ReactNode,
type FC,
type Dispatch,
type SetStateAction,
useEffect,
useRef,
} from "react";
interface Props {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
children: ReactNode;
}
export const Modal: FC<Props> = ({ isOpen, setIsOpen, children }) => {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
document.body.style.top = `${window.scrollY * -1}px`;
document.body.style.position = "fixed";
modalRef.current!.style.top = `${window.scrollY * -1}px`;
} else {
const { top } = document.body.style;
document.body.style.position = "static";
window.scrollTo(0, parseInt(top || "0", 10) * -1);
}
}, [isOpen]);
console.log(window);
return isOpen ? (
<div
ref={modalRef}
style={{ position: "fixed", width: "100vw", height: "100vh" }}
>
<div
style={{
zIndex: 1,
backgroundColor: "rgba(0,0,0,0.8)",
position: "absolute",
top: "50%",
left: "50%",
color: "white",
transform: "translate(-50%, -50%)",
width: "500px",
maxHeight: "80%",
overflow: "auto",
}}
>
<button
onClick={() => {
setIsOpen(false);
}}
>
モーダル非表示
</button>
<div>{children}</div>
</div>
</div>
) : null;
};
parent.tsx
は変更なしです。
最大の注意点は
document.body.style.position = "fixed";
よりも前で
document.body.style.top = `${window.scrollY * -1}px`;
の処理を行わないと、モーダルが一瞬のスキをついて
ページ上部に張り付いてしまいます。
さいごに
styleは自分好みで付け替えると良いと思います。
色々な記事を見ましたが、これがベストかな~という憶測です。
改善があれば、コメントでシバいてください。