🧻

モーダルを開いている時に背面コンテンツのスクロールを抑制する方法

2021/01/22に公開

TAK(@tak_dcxi)です。

モーダルやドロワーメニューを開いている時に背面コンテンツが勝手にスクロールされるとヘイトポイント溜まりがちなので、ユーザビリティ向上のためにも背面コンテンツのスクロールは抑制しておきましょう。

結論

utils/backfaceFixed.ts
// ドキュメントの書字方向を取得し、縦書きかどうかを判定
const isVerticalWritingMode = (): boolean => {
  const writingMode = window.getComputedStyle(document.documentElement).writingMode
  return writingMode.includes('vertical')
}

// スクロールバーの幅を計算する
const getScrollBarSize = (): number => {
  const scrollBarXSize = window.innerHeight - document.body.clientHeight
  const scrollBarYSize = window.innerWidth - document.body.clientWidth
  return isVerticalWritingMode() ? scrollBarXSize : scrollBarYSize
}

// スクロール位置を取得する
const getScrollPosition = (fixed: boolean): number => {
  if (fixed) {
    return isVerticalWritingMode()
      ? document.scrollingElement?.scrollLeft ?? 0
      : document.scrollingElement?.scrollTop ?? 0
  }
  return parseInt(document.body.style.insetBlockStart || '0', 10)
}

type AllowedStyles = 'blockSize' | 'insetInlineStart' | 'position' | 'insetBlockStart' | 'inlineSize'

// 背面固定のスタイルを適用する
const applyStyles = (scrollPosition: number, apply: boolean): void => {
  const styles: Partial<Record<AllowedStyles, string>> = {
    blockSize: '100dvb',
    insetInlineStart: '0',
    position: 'fixed',
    insetBlockStart: isVerticalWritingMode() ? `${scrollPosition}px` : `${scrollPosition * -1}px`,
    inlineSize: '100dvi',
  }
  Object.keys(styles).forEach((key) => {
    const styleKey = key as AllowedStyles
    document.body.style[styleKey] = apply ? styles[styleKey]! : ''
  })
}

// スクロール位置を元に戻す
const restorePosition = (scrollPosition: number): void => {
  const options: ScrollToOptions = {
    behavior: 'instant',
    [isVerticalWritingMode() ? 'left' : 'top']: isVerticalWritingMode() ? scrollPosition : scrollPosition * -1,
  }
  window.scrollTo(options)
}

// 背面を固定する
export const backfaceFixed = (fixed: boolean): void => {
  const scrollBarWidth = getScrollBarSize()
  const scrollPosition = getScrollPosition(fixed)
  document.body.style.paddingInlineEnd = fixed ? `${scrollBarWidth}px` : ''
  applyStyles(scrollPosition, fixed)
  if (!fixed) {
    restorePosition(scrollPosition)
  }
}

使い方

scripts/initializeModal.ts
import { backfaceFixed } from '../utils/backfaceFixed';

// モーダルを開く
const openModal = (modal: HTMLDialogElement): void => {
  // ...

  backfaceFixed(true)

  // ...
}

// モーダルを閉じる
const closeModal = async (modal: HTMLDialogElement): Promise<void> => {
  // ...

  backfaceFixed(false)

  // ...
}

以下、解説です。

スクロールバー消失によるガタツキを防止する

スマートフォンで見る分には問題はないのですが、デスクトップでは背景固定時にスクロールバーが消失することで背面のコンテンツにガタツキが生じる場合があります。

// スクロールバーの幅を計算する
const getScrollBarSize = (): number => {
  const scrollBarXSize = window.innerHeight - document.body.clientHeight
  const scrollBarYSize = window.innerWidth - document.body.clientWidth
  return isVerticalWritingMode() ? scrollBarXSize : scrollBarYSize
}

export const backfaceFixed = (fixed: boolean): void => {
  const scrollBarWidth = getScrollBarSize()
  document.body.style.paddingInlineEnd = fixed ? `${scrollBarWidth}px` : ''

  // ...
}

ウィンドウの内部の横幅と body 要素の横幅の差分でスクロールバーの横幅を計算し、その横幅だけ背景固定時に body 要素に padding を生成することでコンテンツのガタツキを抑えます。レアケースだとは思いますが、もし body に既に padding-inline-end が指定されていたら代わりに border を生成しましょう。

背面コンテンツのスクロールを抑制する

// スクロール位置を取得する
const getScrollPosition = (fixed: boolean): number => {
  if (fixed) {
    return isVerticalWritingMode()
      ? document.scrollingElement?.scrollLeft ?? 0
      : document.scrollingElement?.scrollTop ?? 0
  }
  return parseInt(document.body.style.insetBlockStart || '0', 10)
}

type AllowedStyles = 'blockSize' | 'insetInlineStart' | 'position' | 'insetBlockStart' | 'inlineSize'

// 背面固定のスタイルを適用する
const applyStyles = (scrollPosition: number, apply: boolean): void => {
  const styles: Partial<Record<AllowedStyles, string>> = {
    blockSize: '100dvb',
    insetInlineStart: '0',
    position: 'fixed',
    insetBlockStart: isVerticalWritingMode() ? `${scrollPosition}px` : `${scrollPosition * -1}px`,
    inlineSize: '100dvi',
  }
  Object.keys(styles).forEach((key) => {
    const styleKey = key as AllowedStyles
    document.body.style[styleKey] = apply ? styles[styleKey]! : ''
  })
}

// 背面を固定する
export const backfaceFixed = (fixed: boolean): void => {
  const scrollBarWidth = getScrollBarSize()
  const scrollPosition = getScrollPosition(fixed)
  document.body.style.paddingInlineEnd = fixed ? `${scrollBarWidth}px` : ''
  applyStyles(scrollPosition, fixed)
  if (!fixed) {
    restorePosition(scrollPosition)
  }
}

多くのブラウザは body の高さをビューポートの高さいっぱいに定義してoverflow: hiddenを指定すれば背面のスクロールを抑えることができるのですが、iOS はoverflow: hidden だけでは背面コンテンツを固定することはできません。iOS を含めて背景固定をする場合は body をposition: fixedして無理やり固定させる必要があります。

fixed しただけでは背面コンテンツ固定時にスクロール位置が先頭に戻ってしまうので、現在のスクロール量だけ表示を逆方向にズラす必要が出てきます。inset-block-startの値にドキュメントのスクロール量(横書きならscrollPositionを引いたもの、縦書きならscrollPositionを加えたもの)を指定します。これで固定時にも背面のコンテンツのスクロール位置が移動する現象は起こらなくなります。

なお、コンテンツの内容によっては、固定時に左にコンテンツがズレる現象も起こるので body の横幅は画面いっぱいにしておくと良いでしょう。

固定解除時にはスクロールを取得時の位置に戻す

// スクロール位置を元に戻す
const restorePosition = (scrollPosition: number): void => {
  const options: ScrollToOptions = {
    behavior: 'instant',
    [isVerticalWritingMode() ? 'left' : 'top']: isVerticalWritingMode() ? scrollPosition : scrollPosition * -1,
  }
  window.scrollTo(options)
}

// 背面を固定する
export const backfaceFixed = (fixed: boolean): void => {
  // ...
  if (!fixed) {
    restorePosition(scrollPosition)
  }
}

背面固定解除時にスクロール位置は先頭に戻ってしまうため、固定解除時はスクロール量取得時の位置までスクロールさせる必要が出てきます。

解除時のスクロール量は body のinset-block-startの値を参照します。

おわりに

今回の JS を使ったサンプルはこちらです。

document.scrollingElement に直接 fixed を指定しても固定はできたんだけど、細かい謎のバグ(CodePen だけで発症したので特有のもの?)が起こったので body に指定した。スクロール量の取得もwindow.pageYOffsetで良い気がする。(JS 弱者)

何度か言っていますが、HTML/CSSだけでモーダルなりハンバーガーメニュー作ったりするとこういったところで限界が来るので、JS使って実装したほうが良いと思いますよ。あくまで個人の感想ですが。

2021 年初の投稿はサンプルに出したハンバーガーメニューの作り方について取り上げようと思ったけど、内容がアレなため公開は先になりそう。

今年もよろしくお願いします。

Discussion