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.