😇

【2024】iOSでモーダル表示時に背面のスクロールを固定・抑制する【iOS17.6.1】

2024/09/08に公開

定期的に同じことを考えている気がする…

結論

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でもスクロールを抑制できるようになったという情報を見かけるようになります。

https://zenn.dev/lclco/articles/f5b20817a15b9a

https://x.com/kobitoCode/status/1584211942994247680

https://x.com/oku_log/status/1623996643682160640

これは非常に大きなニュースで、同じくiOS16.0にて対応されたoverscroll-behaviorと合わせて長く続いてきたiOSにおけるスクロール周りの課題が大きく前進したかのように思われました。

しかし、2024-09(iOS17.6.1)に確認したところ…

よ、抑制されていない……。

いつの間にかまたoverflow:hiddenでスクロールを抑制できなくなっている?

結論としては、少なくともiOS17.6.1現在では<body>要素にoverflow: hiddenを付与してもスクロールを抑制できないことを確認済です。

https://x.com/supana0307/status/1808055676956205073

https://x.com/ramiyablog/status/1718411278266536106

可能性としては2つ考えられます。

1. 実は解決していなかった

アドレスバーやタブバーが表示されている時のみスクロールが抑制されるという報告があります。

https://zenn.dev/tak_dcxi/articles/bbdb6cd9305ba4

iOS 16からoverflow: hiddenでもスクロールを抑制できるという文献が多く存在しますが、iOSでスクロールが抑制されるのはアドレスバーが表示されているのみで、スクロール時にアドレスバーが隠れている時はスクロールが抑制されません。そのため、overflow: hiddenはiOSのスクロール抑制方法としては不十分です。

https://x.com/wevmarin/status/1655471521257885696

ただしiOS17.6.1現在、アドレスバー・タブバーが表示されていると逆にスクロールを抑制できないように見えます。

2. 一度は本当に解決していたが、また戻った

iOS15.4にてscroll-behavior、iOS16.0にてoverflow-behaviorとiOS16付近でスクロール周りのCSS対応が進んでいるので、このタイミングでは本当にoverflow:hiddenでスクロールを抑制できたという説です。

手元にiOS16環境がないので確認できませんが、アドレスバー・タブバーが表示されている際の挙動も過去の報告と現在の挙動とではやや異なっていますし、可能性としてはあり得るのかなと思いました。

一通り試してみる

ここで試せます。
https://ios-prevent-scroll-nihashi000s-projects.vercel.app/

リポジトリ。
https://github.com/nih4shi/ios-prevent-scroll

前提

<dialog>要素でモーダルを作成・表示し、背面のスクロール抑制を試みます。

<dialog>要素とbody {overflow: hidden }の組み合わせに問題があるという話もちらっと目にしましたが、確認した限りでは<dialog>要素の使用の有無で特に違いはないように見えたので今回は考慮しません。

ModalCreatedByDialog.tsx
'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を付与

ExampleAddOverflowHiddenToBody.tsx
'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">&lt;body&gt;に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}>&lt;body&gt;にoverflow:hidden</div>
        ))}
      </ModalCreatedByDialog>
    </div>
  )
}

overflowscrollbar-gutter

global.css
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">&lt;html&gt;と&lt;body&gt;に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}>&lt;html&gt;と&lt;body&gt;にoverflow:hidden</div>
        ))}
      </ModalCreatedByDialog>
    </div>
  )
}

結果

gif内、順に

操作1. (モーダル内スクロールの確認)

操作2. アドレスバー・タブバーが非表示の状態でモーダルを表示
→背面のスクロールが抑制される

操作3. アドレスバー・タブバーが表示されている状態でモーダルを表示
→背面のスクロールが抑制されない

操作4. アドレスバー・タブバーが非表示の状態でモーダルを表示・スクロールが抑制された後、画面上部をタップしアドレスバー・タブバーを表示
→背面のスクロールの抑制が解除される

特定条件下においてスクロールを抑制できません。

ただし、<body>要素のみに{overflow: hidden}を付与する場合と比較して、アドレスバー・タブバーが非表示の場合のスクロール抑制には再現性がある(必ず抑制される)ように感じます。

position: fixed+抑制解除時にスクロール位置を元の位置に戻す

下記記事のコードを直接使用しています。
https://zenn.dev/tak_dcxi/articles/bbdb6cd9305ba4

結果

gif内、順に

操作1. (モーダル内スクロールの確認)

操作2. アドレスバー・タブバーが非表示の状態でモーダルを表示
→アドレスバー・タブバーが表示され、背面のスクロールが抑制される

操作3. アドレスバー・タブバーが表示されている状態でモーダルを表示
→背面のスクロールが抑制される

スクロールを抑制できています。

操作2においてアドレスバーが表示されるタイミングでモーダルにがたつきが生じていますが、これはモーダルの表示位置を上下中央で定義していることが原因なのでスタイリングで解消できます(他の実装でのがたつきも同様。リポジトリでは対応済)。

react-remove-scroll

react-remove-scrollというライブラリを使用する方法です。

https://www.npmjs.com/package/react-remove-scroll

ExampleReactRemoveScroll.tsx
'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