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

5 min read読了の目安(約4600字

あけましておめでとうございます。TAK(@tak_dcxi)です。

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

結論

utils/backfaceFixed.js
const backfaceFixed = (fixed) => {
  /**
   * 表示されているスクロールバーとの差分を計測し、背面固定時はその差分body要素に余白を生成する
   */
  const scrollbarWidth = window.innerWidth - document.body.clientWidth;
  document.body.style.paddingRight = fixed ? `${scrollbarWidth}px` : '';

  /**
   * スクロール位置を取得する要素を出力する(`html`or`body`)
   */
  const scrollingElement = () => {
    const browser = window.navigator.userAgent.toLowerCase();
    if ('scrollingElement' in document) return document.scrollingElement;
    if (browser.indexOf('webkit') > 0) return document.body;
    return document.documentElement;
  };

  /**
   * 変数にスクロール量を格納
   */
  const scrollY = fixed
    ? scrollingElement().scrollTop
    : parseInt(document.body.style.top || '0');

  /**
   * CSSで背面を固定
   */
  const styles = {
    height: '100vh',
    left: '0',
    overflow: 'hidden',
    position: 'fixed',
    top: `${scrollY * -1}px`,
    width: '100vw',
  };

  Object.keys(styles).forEach((key) => {
    document.body.style[key] = fixed ? styles[key] : '';
  });

  /**
   * 背面固定解除時に元の位置にスクロールする
   */
  if (!fixed) window.scrollTo(0, scrollY * -1);
};

export { backfaceFixed };

使い方

lib/drawer.js
import { backfaceFixed } from '../utils/backfaceFixed';

const open = () => {
  // 背面コンテンツのスクロールを無効にする
  backfaceFixed(true);
}

const close = () => {
  // 背面コンテンツのスクロールの無効を解除する
  backfaceFixed(false);
}

以下、解説です。

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

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

const scrollbarWidth = window.innerWidth - document.body.clientWidth;
document.body.style.paddingRight = fixed ? `${scrollbarWidth}px` : "";

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

ドキュメントのスクロール位置を取得する

ドキュメントのスクロール要素(htmlorbody)はブラウザによって差異があります。かつては UserAgent などを使ってスクロール要素を出し分けていたそうですが、ある時期から Chrome が body ではなく html をスクロール要素として扱うようになったりとブラウザ・OS 毎の出し分けは効かなくなりました。現在はdocument.scrollingElementを参照すればブラウザ・OS ごとのスクロール要素を取得することが可能です。(以下、参考文献)

https://dev.opera.com/articles/fixing-the-scrolltop-bug/

ただし、document.scrollingElement は古い iOS や IE では利用できないため、レガシーな環境を意識する場合は出し分ける必要があります。

const scrollingElement = () => {
  const browser = window.navigator.userAgent.toLowerCase();
  // document.scrollingElementが有効なブラウザの場合
  if ("scrollingElement" in document) return document.scrollingElement;
  // document.scrollingElementが無効なiOSの場合はbody要素を
  if (browser.indexOf("webkit") > 0) return document.body;
  // その他(IEとか)はhtml要素を
  return document.documentElement;
};
  • document.scrollingElement が利用できるブラウザ・OS はdocument.scrollingElement
  • document.scrollingElement に対応していない古い iOS はbody
  • 他の対応していないブラウザ(専ら IE)にはhtml

をそれぞれ返します。正直 iOS の出し分けはもういらない気がしますが、念の為。

現在のスクロール位置はscrollingElement().scrollTopで取得可能です。

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

const scrollY = fixed
  ? scrollingElement().scrollTop
  : parseInt(document.body.style.top || "0");

const styles = {
  height: "100vh",
  left: "0",
  overflow: "hidden",
  position: "fixed",
  top: `${scrollY * -1}px`,
  width: "100vw",
};

Object.keys(styles).forEach((key) => {
  document.body.style[key] = fixed ? styles[key] : "";
});

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

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

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

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

const scrollY = fixed
  ? scrollingElement().scrollTop
  : parseInt(document.body.style.top || '0');

...

if (!fixed) window.scrollTo(0, scrollY * -1);

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

解除時のスクロール量は body のtopの値を参照します。ただし、この時点では top の値は文字列なのと、ズラした関係で負の値になっているため、数値に変換した上で再度* -1をして取得時のスクロール量に戻してあげましょう。

おわりに

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

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

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

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

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

この記事に贈られたバッジ