2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-08-14

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

今回は、

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

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

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

Animation.gif

やってはいけないこと

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

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

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

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

要件定義

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

  • オーバーレイでページの最も前面に表示される。

  • モーダル内の要素は可変長。

  • childrenとしてモーダルのコンテンツが親から受け渡される。

  • モーダルを閉じると、モーダル内で再生していた動画なども閉じる。

  • 背景が固定される。

設計

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

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

がシンプルそうです。

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

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

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

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;

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

上記の2ファイルで構成されたページのgifです。
Animation.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を付け替えましょう。

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
うまく動いてますね!!!!!!!

さいごに

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

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

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?