[Nextjs]画面下からフェードイン・アウトするモーダルを実装する

Next.jsでモーダルを開くイベントを実行すると、画面下からフェードインするモーダルでいい感じのがなかったので自作した。

要件は下記。

・モーダルの開閉はフェードイン、アウトする。
・モーダルは下から上へ表示する
・モーダル外はオーバーレイを表示し、クリックするとモーダルを閉じる
・モーダル外のオーバーレイはopacityで制御し徐々に表示、徐々に消えるようなアニメーションにする

完成イメージ。

目次

モーダルコンポーネントを実装する

先ほどの要件を満たすコンポーネントを作成します。

propsとstateの定義

propsにはモーダル開閉stateと、モーダル閉じる関数を渡します。
これにより、モーダル開閉制御は呼び出す親コンポーネントに委譲します。
モーダルコンポーネントでは開閉制御に依存する形でオーバーレイを制御するstateを用意します。

interface Props {
  isOpen: boolean
  onClose: () => void
}

export default React.memo(function OptionModal({ isOpen, onClose }: Props) {
  const [isShowOverlay, setIsShowOverlay] = useState(false)

  return <></>
})

react-springを使ったフェードイン、フェードアウト

下からフェードインして、閉じる時にフェードアウトするanimationを作ります。

下からフェードイン、フェードアウトするアニメーション

transformをisOpenのstateを参照して切り替えます。
translateYの値を切り替えることで徐々に下から表示させています。
表示速度などは、configで設定します。

  const fadeModalAnimation = useSpring({
    transform: isOpen ? 'translateY(0%)' : 'translateY(100%)',
    config: {
      duration: 400,
      easing: easings.easeOutQuart
    }
  })

opacityで徐々に表示、徐々に消えるアニメーション

opacityをisOpenのstateを参照して切り替えます。
フェードインのアニメーションと異なるのは、閉じるときだけは、onRest関数でアニメーションの停止を待ってから、オーバーレイを消していることです。
後ほど記載しますが、オーバーレイはz-indexも絡んでくるのでアニメーションの停止を待つようにしています。

  const OpacityOverlayAnimation = useSpring({
    opacity: isOpen ? 1 : 0,
    config: {
      duration: 400,
      easing: easings.easeOutQuart
    },
    // Overlayを閉じるときは、アニメーションの停止を待つ
    onRest: () => {
      if (!isOpen) {
        setIsShowOverlay(false)
      }
    }
  })

animated.divでstyleを定義

react-springのanimated.divでstyleにアニメーションを設定します。

AnimatedOverlayはモーダルの外に薄黒いカラーでオーバーレイを表示します。
ここはクリックされた時のイベントonClickを定義します。
また、$isOverlayOpenを入れることで、z-ndexによる表示制御を行います。

Animatedはモーダルの実態です。
fadeModalAnimationの効果で下からフェードイン、フェードアウトします。

  return (
    <>
      <AnimatedOverlay
        style={{
          ...OpacityOverlayAnimation
        }}
        $isOverlayOpen={isShowOverlay}
        onClick={() => onClose()}
      />
      <Animated
        style={{
          ...fadeModalAnimation
        }}
      >
       {/* モーダルで表示したい内容 */}
      </Animated>
    </>
  )
})

const AnimatedOverlay = styled(animated.div)<any & { $isOverlayOpen: boolean }>`
  background-color: rgba(0, 0, 0, 0.4);
  z-index: ${({ $isOverlayOpen }) => ($isOverlayOpen ? 10 : -1)};
  overflow: auto;
  display: flex;
  position: fixed;
  top: 0px;
  left: 0px;
  width: 100%;
  height: 100%;
  justify-content: center;
  box-sizing: border-box;
`

const Animated = styled(animated.div)<any>`
  display: flex;
  justify-content: center;
  position: fixed;
  left: 0;
  bottom: 0;
  z-index: 100;
  height: fit-content;
  border-bottom: 1px solid #00000014;
  background-color: #fff;
`

モーダルが表示された時に、背景を固定する

モーダルが表示された時に、背景を固定します。

htmlタグにstyle overflow:hiddenをつけることで背景を固定します。
isOpenがtrueのときにオーバーレイのstateも更新して表示するようにしています。

  useEffect(() => {
    const htmlElement = document.documentElement
    if (isOpen) {
      htmlElement.style.overflow = 'hidden'
      setIsShowOverlay(true)
    } else {
      htmlElement.style.overflow = ''
    }

    return () => {
      htmlElement.style.overflow = ''
    }
  }, [isOpen])

iOSで背景がスクロールされてしまう問題。

overflow:hiddenでスクロール固定していも、iOS safariでは、何かしらのタイミングで裏側のコンテンツがスクロールできてしまう問題がありました。

この件は、ICS様のブログを参考にさせていただきました。感謝です。

まずは、scrollLock関数を作成して、

  /**
   * 指定した要素以外のスクロールを抑止
   */
  const scrollLock = (event: TouchEvent) => {
    // スクロール可能な要素か
    const isScrollable = (element: Element) => element.clientHeight < element.scrollHeight

    const canScrollElement = (event.target as HTMLElement)?.closest('#option_modal')
    if (canScrollElement === null) {
      // 対象の要素でなければスクロール禁止
      event.preventDefault()

      return
    }

    if (canScrollElement && isScrollable(canScrollElement)) {
      // 対象の要素があり、その要素がスクロール可能であればスクロールを許可する
      event.stopPropagation()
    } else {
      // 対象の要素はスクロール禁止
      event.preventDefault()
    }
  }

useEffect内で制御するようにします。
isOpenのstateによって、touchmoveを制御してiOS safariでも背景スクロールをロックします。

  useEffect(() => {
    const htmlElement = document.documentElement
    if (isOpen) {
      htmlElement.style.overflow = 'hidden'
      document.addEventListener('touchmove', scrollLock, { passive: false })
      setIsShowOverlay(true)
    } else {
      htmlElement.style.overflow = ''
      document.removeEventListener('touchmove', scrollLock)
    }

    return () => {
      htmlElement.style.overflow = ''
      document.removeEventListener('touchmove', scrollLock)
    }
  }, [isOpen])

コンポーネントのコード全体

コピペで呼び出すだけで利用できます。

import styled from 'styled-components'
import React, { useEffect, useState } from 'react'
import { useSpring, animated, easings } from 'react-spring'

interface Props {
  isOpen: boolean
  onClose: () => void
}

export default React.memo(function OptionModal({ isOpen, onClose }: Props) {
  const [isShowOverlay, setIsShowOverlay] = useState(false)

  /**
   * 指定した要素以外のスクロールを抑止
   */
  const scrollLock = (event: TouchEvent) => {
    // スクロール可能な要素か
    const isScrollable = (element: Element) => element.clientHeight < element.scrollHeight

    const canScrollElement = (event.target as HTMLElement)?.closest('#option_modal')
    if (canScrollElement === null) {
      // 対象の要素でなければスクロール禁止
      event.preventDefault()

      return
    }

    if (canScrollElement && isScrollable(canScrollElement)) {
      // 対象の要素があり、その要素がスクロール可能であればスクロールを許可する
      event.stopPropagation()
    } else {
      // 対象の要素はスクロール禁止
      event.preventDefault()
    }
  }

  // 背景のscrollとtouchmoveを制御。Modalを開く時にOverlayも表示する。
  useEffect(() => {
    const htmlElement = document.documentElement
    if (isOpen) {
      htmlElement.style.overflow = 'hidden'
      document.addEventListener('touchmove', scrollLock, { passive: false })
      setIsShowOverlay(true)
    } else {
      htmlElement.style.overflow = ''
      document.removeEventListener('touchmove', scrollLock)
    }

    return () => {
      htmlElement.style.overflow = ''
      document.removeEventListener('touchmove', scrollLock)
    }
  }, [isOpen])

  const fadeModalAnimation = useSpring({
    transform: isOpen ? 'translateY(0%)' : 'translateY(100%)',
    config: {
      duration: 400,
      easing: easings.easeOutQuart
    }
  })

  const OpacityOverlayAnimation = useSpring({
    opacity: isOpen ? 1 : 0,
    config: {
      duration: 400,
      easing: easings.easeOutQuart
    },
    // Overlayを閉じるときは、アニメーションの停止を待つ
    onRest: () => {
      if (!isOpen) {
        setIsShowOverlay(false)
      }
    }
  })

  return (
    <>
      <AnimatedOverlay
        style={{
          ...OpacityOverlayAnimation
        }}
        $isOverlayOpen={isShowOverlay}
        onClick={() => onClose()}
      />
      <Animated
        style={{
          ...fadeModalAnimation
        }}
      >
        <ModalMain id='option_modal'>
          <ModalHeaderRoot>
            <HeaderTitle>モーダル画面</HeaderTitle>
          </ModalHeaderRoot>
          <ItemWrapper>
            <Item>アイテム1</Item>
            <Item>アイテム1</Item>
          </ItemWrapper>
        </ModalMain>
      </Animated>
    </>
  )
})

const AnimatedOverlay = styled(animated.div)<any & { $isOverlayOpen: boolean }>`
  background-color: rgba(0, 0, 0, 0.4);
  z-index: ${({ $isOverlayOpen }) => ($isOverlayOpen ? 10 : -1)};
  overflow: auto;
  display: flex;
  position: fixed;
  top: 0px;
  left: 0px;
  width: 100%;
  height: 100%;
  justify-content: center;
  box-sizing: border-box;
`

const Animated = styled(animated.div)<any>`
  display: flex;
  justify-content: center;
  position: fixed;
  left: 0;
  bottom: 0;
  z-index: 100;
  height: fit-content;
  border-bottom: 1px solid #00000014;
  background-color: #fff;
`

const ModalMain = styled.div`
  background-color: rgba(0, 0, 0, 0.4);
  overflow: auto;
  padding: 0px;
  position: relative;
  height: fit-content;
  width: 100vw;
  background-color: #fff;
`

const ModalHeaderRoot = styled.div`
  height: 48px;
  display: grid;
  align-content: center;
  justify-content: center;
`

const HeaderTitle = styled.h3`
  font-size: 16px;
  color: #000;
  font-weight: bold;
`

const ItemWrapper = styled.ul`
  margin: 0;
  padding: 0;
`

const Item = styled.li`
  font-size: 16px;
  color: #000;
  font-weight: bold;
  list-style: none;
  width: 100%;
  height: 40px;
  display: block;
  line-height: 40px;
  cursor: pointer;
`

モーダルコンポーネントを呼び出す

モーダルコンポーネントを呼び出します。

まずは、モーダルの開閉stateを定義します。
デフォルトはfalseで、モーダルは閉じている状態にします。

const [isOpenOptionModal, setIsOpenOptionModal] = useState(false)

モーダルを開くボタンを用意します。
onClickにstateを設定し、onClickするとtrueがセットされるようにします。

<button onClick={() => setIsOpenOptionModal(true)}>モーダルを開く</button>

モーダルを呼び出します。
isOpenに現在の状態のstateを設定し、onCloseに閉じる時の処理を書きます。

<OptionModal isOpen={isOpenOptionModal} onClose={() => setIsOpenOptionModal(false)} />

これで、モーダルコンポーネントを呼び出すことができます。

pageのコード全体

サンプルにページ全体のコードものせておきます。

import { useState } from 'react'
import styled from 'styled-components'
import React from 'react'
import OptionModal from '@/components/Modal/OptionModal'

export default function Test() {
  const [isOpenOptionModal, setIsOpenOptionModal] = useState(false)

  return (
      <Main>
        <button onClick={() => setIsOpenOptionModal(true)}>モーダルを開く</button>
        <OptionModal isOpen={isOpenOptionModal} onClose={() => setIsOpenOptionModal(false)} />
      </Main>
  )
}

const Main = styled.main`
  margin-top: 68px;
  height: 100vh;
  @media screen and (max-width: 767px) {
    margin-top: 48px;
  }
`

Summary

react-springを使ったアニメーションの実装が苦労ポイントでした。
ドキュメントを読んだ方が良いですが、読み込む時間がないときはだいたいハマります。

よかったらシェアしてね!
目次