Skip to content

Instantly share code, notes, and snippets.

@manabuyasuda
Last active February 28, 2025 08:12
Show Gist options
  • Save manabuyasuda/af64d84d14cf0f64d9450b2ee2a04c01 to your computer and use it in GitHub Desktop.
Save manabuyasuda/af64d84d14cf0f64d9450b2ee2a04c01 to your computer and use it in GitHub Desktop.
import { useEffect, useCallback, useRef } from 'react';
// ブラウザ環境かどうかを判定する
const isBrowser = typeof window !== 'undefined';
// iOSデバイスかどうかを判定する
const isIosDevice =
isBrowser &&
window.navigator &&
window.navigator.platform &&
/iP(ad|hone|od)/.test(window.navigator.platform);
// 背景固定時に保存する元のスタイル情報
type BodyInfo = {
position: string;
blockSize: string;
inlineSize: string;
insetBlockStart: string;
insetInlineStart: string;
paddingInlineEnd: string;
};
/**
* bodyのスクロールをロックするためのカスタムフックです。
* モーダルやダイアログ表示時にページのスクロールを防ぎ、スクロール位置を保持します。
* @see https://github.com/streamich/react-use/blob/master/src/useLockBodyScroll.ts
* @see https://zenn.dev/tak_dcxi/articles/bbdb6cd9305ba4
* @example
* function ExampleModal({ isOpen }) {
* useLockBodyScroll(isOpen);
* return isOpen ? <div>モーダルの内容</div> : null;
* }
*/
export const useLockBodyScroll = (isLocked: boolean): void => {
// bodyへの参照を保持する
const bodyRef = useRef<HTMLElement | null>(null);
// スクロール位置を保持する
const scrollPositionRef = useRef<number>(0);
// 背景の固定状態を保持する
const isLockedRef = useRef(false);
// 元のスタイル情報を保持する
const savedBodyInfoRef = useRef<BodyInfo | null>(null);
// タッチイベントリスナーが追加されているかどうかを保持する
const documentListenerAddedRef = useRef(false);
/**
* タッチイベントのハンドラーです。
* シングルタッチ(1本指での操作)の場合のみスクロールを防ぎます。
* ピンチインや拡大縮小などのマルチタッチ操作は有効のままです。
*/
const touchMoveHandler = useCallback((e: TouchEvent) => {
if (e.touches.length > 1) return;
e.preventDefault();
}, []);
/**
* スクロール位置を取得します。
*/
const getScrollPosition = useCallback((): number => {
return document.scrollingElement?.scrollTop ?? 0;
}, []);
/**
* スクロールバーの幅を計算します。
*/
const getScrollBarSize = useCallback((): number => {
// ウィンドウの幅 - スクロールバーを除いたコンテンツ幅
return window.innerWidth - document.body.clientWidth;
}, []);
/**
* bodyのスタイルを一括で設定します
*/
const setBodyStyles = useCallback(
(body: HTMLElement, styles: Partial<BodyInfo>) => {
Object.entries(styles).forEach(([key, value]) => {
if (value !== undefined) {
body.style[key as keyof BodyInfo] = value;
}
});
},
[],
);
/**
* 背景固定のスタイルを適用します。
*/
const lock = useCallback(
(body: HTMLElement) => {
// 固定済みなら何もしない
if (isLockedRef.current) return;
// 現在のスクロール位置を保存する
const currentScrollPosition = window.scrollY;
// スクロール位置を保存する
scrollPositionRef.current = currentScrollPosition;
// スクロールバーの幅を取得する
const scrollBarWidth = getScrollBarSize();
// 固定前のスタイルを保存する
savedBodyInfoRef.current = {
position: body.style.position,
blockSize: body.style.blockSize,
inlineSize: body.style.inlineSize,
insetBlockStart: body.style.insetBlockStart,
insetInlineStart: body.style.insetInlineStart,
paddingInlineEnd: body.style.paddingInlineEnd,
};
// iOSのバウンスを防ぐために、touchmoveイベントをキャンセルします
if (isIosDevice && !documentListenerAddedRef.current) {
document.addEventListener('touchmove', touchMoveHandler, {
passive: false,
});
documentListenerAddedRef.current = true;
}
// 背景を固定するスタイルを適用します
// 現在のスクロール位置で固定し、スクロールバーの分だけ余白を追加してガタつきを防ぎます
setBodyStyles(body, {
position: 'fixed',
blockSize: '100dvb',
inlineSize: '100dvi',
insetBlockStart: `${currentScrollPosition * -1}px`,
insetInlineStart: '0',
paddingInlineEnd: `${scrollBarWidth}px`,
});
// 背景が固定状態であると記録する
isLockedRef.current = true;
},
[getScrollPosition, getScrollBarSize, touchMoveHandler, setBodyStyles],
);
/**
* 背景の固定を解除します。
* 1. 保存された元のスタイルを復元する
* 2. iOSデバイスの場合、タッチ操作の制御を解除する
* 3. スクロール位置を復元する
*/
const unlock = useCallback(
(body: HTMLElement) => {
// 固定されていないか、固定前のスタイル情報が保存されていない場合は何もしない
if (!isLockedRef.current || !savedBodyInfoRef.current) return;
// `lock`で追加したイベントリスナーを解除します
if (isIosDevice && documentListenerAddedRef.current) {
document.removeEventListener('touchmove', touchMoveHandler);
// タッチイベントリスナーは解除されていることを記録する
documentListenerAddedRef.current = false;
}
// 元のスタイルを復元する
setBodyStyles(body, savedBodyInfoRef.current);
// スクロール位置を復元する
window.scrollTo({
top: scrollPositionRef.current,
behavior: 'instant',
});
// 固定前のスタイル情報をクリアする
savedBodyInfoRef.current = null;
// 固定が解除されたことを記録する
isLockedRef.current = false;
},
[touchMoveHandler, setBodyStyles],
);
useEffect(() => {
// SSR環境では実行しない
if (!isBrowser) return;
// ドキュメントのbody要素を取得する
const body = document.body;
// body要素への参照を保持する
bodyRef.current = body;
if (isLocked) {
lock(body);
} else {
unlock(body);
}
return () => {
if (bodyRef.current && isLocked) {
unlock(bodyRef.current);
}
};
}, [isLocked, lock, unlock]);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment