[Nextjs]Implement a modal that fades in and out from the bottom of the screen

I couldn’t find a good modal that fades in from the bottom of the screen when the modal open event is executed in Next.js, so I made my own.

The requirements are as follows.

・Modal opening and closing fade in and out.
・Modals are displayed from bottom to top.
・An overlay is displayed outside the modal, and the modal is closed when clicked.
・The overlay outside the modal is controlled by opacity to make the animation appear and disappear gradually.

Completed image.

目次

Implement modal components

Create a component that meets the previous requirement.

Definition of props and state

In props, pass a modal open/close state and a modal close function.
This delegates control of the modal open/close to the parent component that calls it.
The modal component provides a state that controls the overlay in a way that depends on the open/close control.

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

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

  return <></>
})

Fade-in and fade-out using react-spring

Create an animation that fades in from the bottom and fades out when closed.

Animation fading in and out from the bottom

The transform is switched by reference to the state of isOpen.
By switching the value of translateY, the display is gradually moved from the bottom.
The display speed and other settings can be configured in config.

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

Gradual display and gradual disappearance animation with opacity

The opacity is switched by reference to the state of isOpen.
The only difference from the fade-in animation is that when closing, we wait for the animation to stop using the onRest function before removing the overlay.
As will be explained later, the overlay also involves the z-index, so we wait for the animation to stop.

  const OpacityOverlayAnimation = useSpring({
    opacity: isOpen ? 1 : 0,
    config: {
      duration: 400,
      easing: easings.easeOutQuart
    },
    // When closing the Overlay, wait for the animation to stop
    onRest: () => {
      if (!isOpen) {
        setIsShowOverlay(false)
      }
    }
  })

Define style in animated.div

In react-spring’s animated.div, set the animation to style.

AnimatedOverlay displays an overlay in a light black color outside the modal.
This is where you define the onClick event when clicked.
Also, by including $isOverlayOpen, the display is controlled by z-ndex.

Animated is the actual state of the modal.
It fades in and out from below with the fadeModalAnimation effect.

  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;
`

Fix the background when the modal is displayed

Fixes the background when the modal is displayed.

The background is fixed by adding style overflow:hidden to the html tag.
The overlay state is also updated and displayed when isOpen is true.

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

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

Problem with scrolling background on iOS.

Even though the scrolling is fixed with overflow:hidden, there was a problem with iOS safari that the content on the back side could be scrolled at some point.

I referred to ICS’s blog for this issue. Thanks.

First, create a scrollLock function,

  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()
    }
  }

Control it within useEffect.
The isOpen state controls touchmove and locks background scrolling even in 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])

Entire code of the component

It can be used simply by calling it with a copy and paste.

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;
`

Calling a Modal Component

Call the modal component.

First, define the open/close state of the modal.
Defaults to false, which leaves the modal in the closed state.

const [isOpenOptionModal, setIsOpenOptionModal] = useState(false)

Prepare a button to open the modal.
Set state to onClick so that true is set when onClick is performed.

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

Call the modal.
Set the current state in isOpen and write the closing process in onClose.

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

Now you can call the modal component.

The entire code of page

The code for the entire page is also included as a sample.

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

The hardest part was implementing the animation using react-spring.
It is better to read the documentation, but I usually get stuck when I don’t have time to read it.

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