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