react-scroll-blocker
Version:
A modern React 18 compatible scroll lock component for preventing body scroll
327 lines (321 loc) • 12.8 kB
JavaScript
import { jsx } from 'react/jsx-runtime';
import React, { useRef, useEffect, cloneElement, useState, useCallback } from 'react';
/**
* Utility functions for scroll lock functionality
*/
/**
* Gets the current scrollbar width
*/
function getScrollbarWidth() {
var _a;
// Create a temporary element to measure scrollbar width
const outer = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.overflow = 'scroll';
outer.style.msOverflowStyle =
'scrollbar'; // needed for WinJS apps
document.body.appendChild(outer);
const inner = document.createElement('div');
outer.appendChild(inner);
const scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
// Clean up
(_a = outer.parentNode) === null || _a === void 0 ? void 0 : _a.removeChild(outer);
return scrollbarWidth;
}
/**
* Checks if the current device is iOS (mobile devices only, not macOS)
*/
function isIOS() {
// More precise iOS detection that excludes macOS
return (/iPad|iPhone|iPod/.test(navigator.userAgent) &&
!window.MSStream &&
'ontouchstart' in window &&
!/Macintosh/.test(navigator.userAgent));
}
/**
* Safely prevents the default behavior of an event if it's cancelable
*/
function preventDefault(event) {
if (event.cancelable) {
event.preventDefault();
}
}
/**
* Checks if an element is scrollable
*/
function isScrollable(element) {
const { overflow, overflowX, overflowY } = window.getComputedStyle(element);
return /auto|scroll/.test(overflow + overflowX + overflowY);
}
/**
* Finds the closest scrollable parent of an element
*/
function findScrollableParent(element) {
if (!element || element === document.body) {
return null;
}
if (isScrollable(element)) {
return element;
}
return findScrollableParent(element.parentElement);
}
/**
* TouchScrollable component that allows an element to remain scrollable
* even when ScrollLock is active. This is particularly important for iOS
* devices where overflow: hidden on body doesn't prevent touch scrolling.
*/
const TouchScrollable = ({ children }) => {
const elementRef = useRef(null);
useEffect(() => {
const element = elementRef.current;
if (!element)
return;
let startY = 0;
let allowTouchMove = false;
const handleTouchStart = (event) => {
if (event.touches.length === 1) {
startY = event.touches[0].clientY;
// Check if the touch target or its parent is scrollable
const target = event.target;
const scrollableParent = findScrollableParent(target) || target;
allowTouchMove = isScrollable(scrollableParent);
}
};
const handleTouchMove = (event) => {
if (event.touches.length !== 1)
return;
const touch = event.touches[0];
const currentY = touch.clientY;
const element = elementRef.current;
if (!element || !allowTouchMove)
return;
const deltaY = currentY - startY;
const scrollTop = element.scrollTop;
const scrollHeight = element.scrollHeight;
const clientHeight = element.clientHeight;
// Check if we're at the boundaries of the scrollable element
const isAtTop = scrollTop === 0 && deltaY > 0;
const isAtBottom = scrollTop + clientHeight >= scrollHeight && deltaY < 0;
// Prevent rubber band scrolling at the boundaries
if (isAtTop || isAtBottom) {
event.preventDefault();
}
};
// Add touch event listeners
element.addEventListener('touchstart', handleTouchStart, { passive: false });
element.addEventListener('touchmove', handleTouchMove, { passive: false });
return () => {
element.removeEventListener('touchstart', handleTouchStart);
element.removeEventListener('touchmove', handleTouchMove);
};
}, []);
// Clone the child element and add our ref and data attribute
const child = React.Children.only(children);
return cloneElement(child, {
ref: elementRef,
'data-scroll-lock-scrollable': true,
...child.props,
});
};
/**
* Track active ScrollBlocker instances using a Set for better management
*/
const activeBlockers = new Set();
/**
* Generate unique ID for each ScrollBlocker instance
*/
let instanceCounter = 0;
const generateInstanceId = () => `scroll-blocker-${++instanceCounter}`;
/**
* Reset all ScrollBlocker instances - useful for emergency cleanup
*/
const resetScrollBlocker = () => {
var _a;
// Clear all active instances
activeBlockers.clear();
// Restore body styles to default
const body = document.body;
body.style.overflow = '';
body.style.position = '';
body.style.paddingRight = '';
// Clean up global original styles reference
delete window.__scrollBlockerOriginalStyles;
// Remove all touch event listeners
const clonedBody = body.cloneNode(true);
(_a = body.parentNode) === null || _a === void 0 ? void 0 : _a.replaceChild(clonedBody, body);
// Clean up any data attributes
const existingElements = document.querySelectorAll('[data-scroll-lock-scrollable]');
existingElements.forEach((el) => {
el.removeAttribute('data-scroll-lock-scrollable');
});
};
/**
* ScrollBlocker component that prevents scrolling on the document body.
*
* Features:
* - Prevents body scroll when mounted and active
* - Accounts for scrollbar width to prevent layout shift
* - Supports nested scroll blocker instances
* - Handles iOS touch scrolling issues
* - Allows specific child elements to remain scrollable
*/
const ScrollBlocker = ({ isActive = true, accountForScrollbars = true, children, }) => {
const [isBlocking, setIsBlocking] = useState(false);
const instanceIdRef = useRef();
const touchMoveListenerRef = useRef(null);
// Generate unique instance ID on first render
if (!instanceIdRef.current) {
instanceIdRef.current = generateInstanceId();
}
useEffect(() => {
if (!isActive || isBlocking) {
return;
}
const instanceId = instanceIdRef.current;
const body = document.body;
// Add this instance to active set
activeBlockers.add(instanceId);
// Apply scroll blocking styles (only if first instance)
if (activeBlockers.size === 1) {
// Store the ORIGINAL styles globally on first instance only
if (!window.__scrollBlockerOriginalStyles) {
window.__scrollBlockerOriginalStyles = {
overflow: body.style.overflow,
position: body.style.position,
paddingRight: body.style.paddingRight,
};
}
const scrollbarWidth = accountForScrollbars ? getScrollbarWidth() : 0;
body.style.overflow = 'hidden';
body.style.position = 'relative'; // ← Add this line!
if (scrollbarWidth > 0 && accountForScrollbars) {
body.style.paddingRight = `${scrollbarWidth}px`;
}
// iOS Safari touch handling
if (isIOS()) {
const preventTouchMove = (event) => {
const target = event.target;
if (!target.closest('[data-scroll-lock-scrollable]')) {
if (event.cancelable && event.type === 'touchmove') {
preventDefault(event);
}
}
};
touchMoveListenerRef.current = preventTouchMove;
document.addEventListener('touchmove', preventTouchMove, {
passive: false,
});
}
}
setIsBlocking(true);
// Cleanup function - component-scoped
return () => {
activeBlockers.delete(instanceId);
setIsBlocking(false);
// Only restore styles when no more active blockers
if (activeBlockers.size === 0) {
const originalStyles = window.__scrollBlockerOriginalStyles;
body.style.overflow = (originalStyles === null || originalStyles === void 0 ? void 0 : originalStyles.overflow) || '';
body.style.position = (originalStyles === null || originalStyles === void 0 ? void 0 : originalStyles.position) || '';
body.style.paddingRight = (originalStyles === null || originalStyles === void 0 ? void 0 : originalStyles.paddingRight) || '';
// Clean up global reference
delete window.__scrollBlockerOriginalStyles;
// Remove touch listener
if (touchMoveListenerRef.current) {
document.removeEventListener('touchmove', touchMoveListenerRef.current);
touchMoveListenerRef.current = null;
}
}
};
}, [isActive, accountForScrollbars]);
// If children are provided, wrap them in TouchScrollable
if (children) {
return jsx(TouchScrollable, { children: children });
}
return null;
};
/**
* A React hook that provides scroll blocking functionality.
* This is useful when you want to control scroll blocking programmatically
* without using the ScrollBlocker component.
*
* @param isBlocked - Whether scroll should be blocked
* @param accountForScrollbars - Whether to account for scrollbar width
* @returns An object with methods to manually block/unblock scroll
*/
function useScrollBlocker(isBlocked = false, accountForScrollbars = true) {
const lockCounterRef = useRef(0);
const originalStylesRef = useRef({ overflow: '', paddingRight: '' });
const touchMoveListenerRef = useRef(null);
const blockScroll = useCallback(() => {
lockCounterRef.current++;
if (lockCounterRef.current === 1) {
const body = document.body;
originalStylesRef.current.overflow = body.style.overflow;
originalStylesRef.current.paddingRight = body.style.paddingRight;
const scrollbarWidth = accountForScrollbars ? getScrollbarWidth() : 0;
body.style.overflow = 'hidden';
if (scrollbarWidth > 0 && accountForScrollbars) {
body.style.paddingRight = `${scrollbarWidth}px`;
}
if (isIOS()) {
const preventTouchMove = (event) => {
const target = event.target;
if (!target.closest('[data-scroll-lock-scrollable]')) {
preventDefault(event);
}
};
touchMoveListenerRef.current = preventTouchMove;
document.addEventListener('touchmove', preventTouchMove, {
passive: false,
});
}
}
}, [accountForScrollbars]);
const unblockScroll = useCallback(() => {
if (lockCounterRef.current > 0) {
lockCounterRef.current--;
if (lockCounterRef.current === 0) {
const body = document.body;
body.style.overflow = originalStylesRef.current.overflow;
body.style.paddingRight = originalStylesRef.current.paddingRight;
if (touchMoveListenerRef.current) {
document.removeEventListener('touchmove', touchMoveListenerRef.current);
touchMoveListenerRef.current = null;
}
}
}
}, []);
useEffect(() => {
if (isBlocked) {
blockScroll();
}
else {
unblockScroll();
}
return () => {
unblockScroll();
};
}, [isBlocked, accountForScrollbars, blockScroll, unblockScroll]);
// Cleanup on unmount
useEffect(() => {
return () => {
// Force unblock when component unmounts
if (lockCounterRef.current > 0) {
const body = document.body;
body.style.overflow = originalStylesRef.current.overflow;
body.style.paddingRight = originalStylesRef.current.paddingRight;
if (touchMoveListenerRef.current) {
document.removeEventListener('touchmove', touchMoveListenerRef.current);
}
}
};
}, []);
return {
blockScroll,
unblockScroll,
isBlocked: lockCounterRef.current > 0,
};
}
export { ScrollBlocker, TouchScrollable, ScrollBlocker as default, resetScrollBlocker, useScrollBlocker };
//# sourceMappingURL=index.esm.js.map