【2024】iOSでモーダル表示時に背面のスクロールを固定・抑制する【iOS17.6.1】
定期的に同じことを考えている気がする…
結論
iOS17.6.1現在
方法 | 結果 |
---|---|
<body> 要素にoverflow: hidden
|
× |
<html> 要素, <body> 要素にoverflow: hidden
|
× |
position: fixed +抑制解除時にスクロール位置を元の位置に戻す |
〇 |
react-remove-scroll ※{passive: false} を使用 |
〇 |
はじめに
問題の背景
モーダルを実装する際、UXの観点から背面のスクロールの抑制を求められることが多いです。
- ユーザーの関心をモーダルに集中させるため
- モーダルを閉じた後、元の操作にスムーズに戻すため
- モーダル自身もスクロールを持つ場合、モーダルをスクロールしたつもりが背面がスクロールするとUXが著しく損なわれるため
背面のスクロールを抑制する方法としては、<body>
要素にoverflow: hidden
を付与する方法が広く知られています。
body.modal-open {
overflow: hidden;
}
しかし同時に、この方法ではiOSでスクロールを完全に抑制することはできないことも知られています。iOSのシェア率は無視できる数字ではないので、必然的に別の方法でスクロール抑制を実装する必要が出てきます。
iOS16ではoverflow:hiddenでスクロールを抑制できる?
そんな中、2023年にiOS16からは上記CSSでもスクロールを抑制できるようになったという情報を見かけるようになります。
これは非常に大きなニュースで、同じくiOS16.0にて対応されたoverscroll-behavior
と合わせて長く続いてきたiOSにおけるスクロール周りの課題が大きく前進したかのように思われました。
しかし、2024-09(iOS17.6.1)に確認したところ…
よ、抑制されていない……。
いつの間にかまたoverflow:hiddenでスクロールを抑制できなくなっている?
結論としては、少なくともiOS17.6.1現在では<body>
要素にoverflow: hidden
を付与してもスクロールを抑制できないことを確認済です。
可能性としては2つ考えられます。
1. 実は解決していなかった
アドレスバーやタブバーが表示されている時のみスクロールが抑制されるという報告があります。
iOS 16からoverflow: hiddenでもスクロールを抑制できるという文献が多く存在しますが、iOSでスクロールが抑制されるのはアドレスバーが表示されているのみで、スクロール時にアドレスバーが隠れている時はスクロールが抑制されません。そのため、overflow: hiddenはiOSのスクロール抑制方法としては不十分です。
ただしiOS17.6.1現在、アドレスバー・タブバーが表示されていると逆にスクロールを抑制できないように見えます。
2. 一度は本当に解決していたが、また戻った
iOS15.4にてscroll-behavior
、iOS16.0にてoverflow-behavior
とiOS16付近でスクロール周りのCSS対応が進んでいるので、このタイミングでは本当にoverflow:hidden
でスクロールを抑制できたという説です。
手元にiOS16環境がないので確認できませんが、アドレスバー・タブバーが表示されている際の挙動も過去の報告と現在の挙動とではやや異なっていますし、可能性としてはあり得るのかなと思いました。
一通り試してみる
ここで試せます。
リポジトリ。
前提
<dialog>
要素でモーダルを作成・表示し、背面のスクロール抑制を試みます。
<dialog>
要素とbody {overflow: hidden }
の組み合わせに問題があるという話もちらっと目にしましたが、確認した限りでは<dialog>
要素の使用の有無で特に違いはないように見えたので今回は考慮しません。
'use client'
import { useEffect, useRef } from 'react'
export default function ModalCreatedByDialog({
isOpen,
onClose,
children,
}: {
isOpen: boolean
onClose(): void
children: React.ReactNode
}) {
const modalRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
if (isOpen) {
modalRef.current?.showModal()
} else {
modalRef.current?.close()
}
}, [isOpen])
const handleClose = () => {
if (onClose) {
onClose()
}
}
return (
<dialog
ref={modalRef}
onClick={handleClose}
className="w-full rounded-lg backdrop:bg-black/70 md:max-w-3xl"
>
<div
className="h-52 overflow-y-scroll bg-white p-4 md:max-w-3xl"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</dialog>
)
}
<body>要素にoverflow: hiddenを付与
'use client'
import { useState } from 'react'
import ModalCreatedByDialog from '../ModalCreatedByDialog'
export default function ExampleAddOverflowHiddenToBody() {
const [isModalOpen, setIsModalOpen] = useState(false)
const openModal = () => {
document.body.style.overflow = 'hidden'
document.body.style.scrollbarGutter = 'stable'
setIsModalOpen(true)
}
const closeModal = () => {
document.body.style.overflow = ''
document.body.style.scrollbarGutter = ''
setIsModalOpen(false)
}
return (
<div>
<h2 className="mb-2 mt-4"><body>にoverflow: hidden</h2>
<button className="w-full rounded-lg bg-gray-700 p-2 text-white" onClick={openModal}>
Open Modal
</button>
<ModalCreatedByDialog isOpen={isModalOpen} onClose={closeModal}>
{[...Array(20)].map((_, index) => (
<div key={index}><body>にoverflow:hidden</div>
))}
</ModalCreatedByDialog>
</div>
)
}
overflow
やscrollbar-gutter
は
body:has(dialog[open]) {
overflow: hidden;
scrollbar-gutter: stable;
}
のようにCSSで書くこともできますが、今回は他の実装パターンに影響を与えないようにするためにjsで書いています。
結果
gif内、順に
操作1. (モーダル内スクロールの確認)
操作2. アドレスバー・タブバーが非表示の状態でモーダルを表示
→背面のスクロールが抑制される(動かないのが正しいのでわかりにくいですが...)
操作3. アドレスバー・タブバーが表示されている状態でモーダルを表示
→背面のスクロールが抑制されない
操作4. アドレスバー・タブバーが非表示の状態でモーダルを表示(操作2と同じ)
→背面のスクロールが抑制されない(アドレスバー・タブバーが非表示の時の抑制に再現性がない)
特定条件下においてスクロールを抑制できず、また抑制できる場合も再現性がありません。
またgifには載っていませんが、アドレスバー・タブバーが非表示の状態でモーダルを表示・スクロールが抑制された後、画面上部をタップしアドレスバー・タブバーを表示すると抑制が解除されます。
<html>要素と<body>要素にoveflow: hiddenを付与
上記に<html>
要素のstyle操作を加えます。
'use client'
import { useState } from 'react'
import ModalCreatedByDialog from '../ModalCreatedByDialog'
export default function ExampleAddOverflowHiddenToHtmlAndBody() {
const [isModalOpen, setIsModalOpen] = useState(false)
const openModal = () => {
document.documentElement.style.overflow = 'hidden'
document.body.style.overflow = 'hidden'
document.body.style.scrollbarGutter = 'stable'
setIsModalOpen(true)
}
const closeModal = () => {
document.documentElement.style.overflow = ''
document.body.style.overflow = ''
document.body.style.scrollbarGutter = ''
setIsModalOpen(false)
}
return (
<div>
<h2 className="mb-2 mt-4"><html>と<body>にoverflow: hidden</h2>
<button className="w-full rounded-lg bg-gray-700 p-2 text-white" onClick={openModal}>
Open Modal
</button>
<ModalCreatedByDialog isOpen={isModalOpen} onClose={closeModal}>
{[...Array(20)].map((_, index) => (
<div key={index}><html>と<body>にoverflow:hidden</div>
))}
</ModalCreatedByDialog>
</div>
)
}
結果
gif内、順に
操作1. (モーダル内スクロールの確認)
操作2. アドレスバー・タブバーが非表示の状態でモーダルを表示
→背面のスクロールが抑制される
操作3. アドレスバー・タブバーが表示されている状態でモーダルを表示
→背面のスクロールが抑制されない
操作4. アドレスバー・タブバーが非表示の状態でモーダルを表示・スクロールが抑制された後、画面上部をタップしアドレスバー・タブバーを表示
→背面のスクロールの抑制が解除される
特定条件下においてスクロールを抑制できません。
ただし、<body>
要素のみに{overflow: hidden}
を付与する場合と比較して、アドレスバー・タブバーが非表示の場合のスクロール抑制には再現性がある(必ず抑制される)ように感じます。
position: fixed+抑制解除時にスクロール位置を元の位置に戻す
下記記事のコードを直接使用しています。
結果
gif内、順に
操作1. (モーダル内スクロールの確認)
操作2. アドレスバー・タブバーが非表示の状態でモーダルを表示
→アドレスバー・タブバーが表示され、背面のスクロールが抑制される
操作3. アドレスバー・タブバーが表示されている状態でモーダルを表示
→背面のスクロールが抑制される
スクロールを抑制できています。
操作2においてアドレスバーが表示されるタイミングでモーダルにがたつきが生じていますが、これはモーダルの表示位置を上下中央で定義していることが原因なのでスタイリングで解消できます(他の実装でのがたつきも同様。リポジトリでは対応済)。
react-remove-scroll
react-remove-scroll
というライブラリを使用する方法です。
'use client'
import { useRef, useEffect, useState } from 'react'
import { RemoveScroll } from 'react-remove-scroll'
import ModalCreatedByDialog from '../ModalCreatedByDialog'
export default function ExampleReactRemoveScroll() {
const modalRef = useRef<HTMLDialogElement>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const openModal = () => setIsModalOpen(true)
const closeModal = () => setIsModalOpen(false)
useEffect(() => {
if (isModalOpen) {
modalRef.current?.showModal()
} else {
modalRef.current?.close()
}
}, [isModalOpen])
return (
<div>
<h2 className="mb-2 mt-4">react-remove-scroll</h2>
<button className="w-full rounded-lg bg-gray-700 p-2 text-white" onClick={openModal}>
Open Modal
</button>
{isModalOpen && (
<RemoveScroll forwardProps noIsolation allowPinchZoom>
<div className="scroll">
<ModalCreatedByDialog isOpen={isModalOpen} onClose={closeModal}>
{[...Array(20)].map((_, index) => (
<div key={index}>react-remove-scroll</div>
))}
</ModalCreatedByDialog>
</div>
</RemoveScroll>
)}
</div>
)
}
※他実
結果
gif内、順に
操作1. (モーダル内スクロールの確認)
操作2. アドレスバー・タブバーが非表示の状態でモーダルを表示
→アドレスバー・タブバーが表示され、背面のスクロールが抑制される
操作3. アドレスバー・タブバーが表示されている状態でモーダルを表示
→背面のスクロールが抑制される
スクロールを抑制できています。
ただし、react-remove-scroll
はイベントリスナーに{passitve: false}
を設定することでスクロールの抑制を実現しています。
{passitve: false}
が設定されているとブラウザはイベントリスナーの処理の完了を待ってからスクロール処理を行うことになるので、レスポンスやパフォーマンスの低下、ひいてはUXの悪化に繋がる危険性があります。
react-remove-scroll
でもスクロール領域が広い場合の設定が提供されていますが、導入を検討する際は事前にパフォーマンス面で問題が生じないか確認するのが良いと思います。
Performance
To do the job this library setup non passive event listener. Chrome dev tools would complain about it, as a performance no-op.
We have to use synchronous scroll/touch handler, and it may affect scrolling performance.
Consider using noIsolation mode, if you have large scrollable areas.
おわりに
<dialog>要素を使用したモーダル実装が快適そうだったので試したかっただけなのに…。
個人的には、「背面のスクロールを抑制したい」という要求に対してpreventDefault()
の使用({passive: false}
)をはじめとしてイベントリスナーに触るのはやや責務が大きくなりすぎる感覚があるのと、別の箇所で操作に対する問題が発生する印象が強いので、今対応するならposition: fixed
+抑制解除時にスクロール位置を元の位置に戻す方法が良いように感じます。
Discussion