【CSS決定版】ReactでModalを最小構成で作る【背景の固定】

こんにちは、まちゅです。

今回は、
- 外部モジュールに頼らず
- 背景固定されており
- 最もミニマムな構成で取り回しが良い

モーダルコンポーネントを作っていきます。
成果物はこちら。

(背景と内容はとても適当です。すみません。)

![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2611905/acae851d-b4ea-c8b6-3ea2-bbdf7c50349a.gif)

## やってはいけないこと

:::note alert
`overscroll-behavior: contain;`
はモーダル内にスクロール要素が無い場合に機能しないので
利用しないでください。
:::

:::note alert
1pxを要素に追加して疑似的にスクロール連鎖を止める方法は
やめてください。
ガタガタします。
:::

:::note alert
つっかえ棒の要素で固定するなど、
ごり押し(物理)はやめてください。非効率です。
:::



不純物が増え、後々ぐちゃぐちゃになっていきます。
しっかりとcssやブラウザの特性を理解しましょう。

## 要件定義


今回はモーダルにおける要件を以下に定めます。

- オーバーレイでページの最も前面に表示される。
- モーダル内の要素は可変長。
- childrenとしてモーダルのコンテンツが親から受け渡される。
- モーダルを閉じると、モーダル内で再生していた動画なども閉じる。

- **背景が固定される。**


## 設計

まずは先ほどの要件の上から4つをさっくりと実装します。

**親コンポーネントにbooleanの`useState`を持たせておき、
子の実装でDOMをマウント、アンマウントさせる**

がシンプルそうです。

親部分にモーダルの表示非表示を切り替える機構があるのは明確なので、
子が表示状態管理の`useState`を持っていると親に渡す必要があるので面倒です。
親から受け取るようにしましょう。


また、モーダルが非表示の際には要素をアンマウントしておかないと、
モーダル上でYoutube等を再生した際は`display:none`では音声が再生され続けてしまうので
`display:none`で表示非表示を切り替えないでください。

以下、サンプルの実装とGIFです。


```tsx:Modal.tsx
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;

```

```tsx:parent.tsx
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を学んでいる方であれば楽勝かと思われます。

上記の２ファイルで構成されたページのgifです。
![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2611905/d8c53c29-6620-5353-e29e-66aab61a5707.gif)

よく見るとわかりますが、まだ背景が固定されていません。

## 【超ポイント】背景の固定
結論、

`top:XXpx`としてあげれば、**fixed要素の位置は固定されます。**
これだけです。

- **モーダル表示時**
    - body要素に`position:fixed`を追加し、topを起点として(現在のスクロール位置*-1)分を上に移動させる。
    - モーダル自体もtopを起点として(現在のスクロール位置*-1)分を上に移動させる。
- **モーダル非表示時**
    - bodyのstyleからtopの値を取得、ユーザーがモーダルを開いた場所に`window.scrollTo`で移動してあげる。

です。




`position:fixed`をstyleに追加した瞬間にtopのstyleが指定されていないと勝手にデフォルト値の0になり、ページが最上部に戻ってしまいます。
それを防ぐためにtopの値も同時に指定してあげます。

今回はuseEffectで`isOpen`の状態を監視してstyleを付け替えましょう。

```tsx:Modal.tsx
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`;``

の処理を行わないと、モーダルが一瞬のスキをついて
ページ上部に張り付いてしまいます。



![Animation.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2611905/acae851d-b4ea-c8b6-3ea2-bbdf7c50349a.gif)
**うまく動いてますね！！！！！！！**


# さいごに

styleは自分好みで付け替えると良いと思います。

色々な記事を見ましたが、これがベストかな～という憶測です。
改善があれば、コメントでシバいてください。