import { useState, useLayoutEffect } from 'react'; | |
// Usage | |
function App(){ | |
// State for our modal | |
const [modalOpen, setModalOpen] = useState(false); | |
return ( | |
<div> | |
<button onClick={() => setModalOpen(true)}>Show Modal</button> | |
<Content /> | |
{modalOpen && ( | |
<Modal | |
title="Try scrolling" | |
content="I bet you you can't! Muahahaha 😈" | |
onClose={() => setModalOpen(false)} | |
/> | |
)} | |
</div> | |
); | |
} | |
function Modal({ title, content, onClose }){ | |
// Call hook to lock body scroll | |
useLockBodyScroll(); | |
return ( | |
<div className="modal-overlay" onClick={onClose}> | |
<div className="modal"> | |
<h2>{title}</h2> | |
<p>{content}</p> | |
</div> | |
</div> | |
); | |
} | |
// Hook | |
function useLockBodyScroll() { | |
useLayoutEffect(() => { | |
// Get original value of body overflow | |
const originalStyle = window.getComputedStyle(document.body).overflow; | |
// Prevent scrolling on mount | |
document.body.style.overflow = 'hidden'; | |
// Re-enable scrolling when component unmounts | |
return () => document.body.style.overflow = originalStyle; | |
}, []); // Empty array ensures effect is only run on mount and unmount | |
} |
It's worth noting that browsers / operating systems that use a non-hiding scrollbar (I think most versions of Windows) will show/hide the scrollbar when you change the body overflow from hidden to visible.
It's a tricky one to work around, but in the past I had some luck with first measuring the width/height of the body and then fixing that size before changing the overflow. That prevents the change of layout when you enable/disable scrolling.
I agree, setting document.body.style.paddingRight
may resolve the problem.
useLayoutEffect(() => {
// Prevent scrolling on mount
document.body.style.overflow = 'hidden';
// Re-enable scrolling when component unmounts
return () => (document.body.style.overflow = 'visible');
}, []); // Empty array ensures effect is only run on mount and unmount
return () => (document.body.style.overflow = 'visible')
It's better to reset document.body.style.overflow into original value, such as auto
/ hidden
(sometimes isn't visible
)
Writing these with hooks is great, which decreases the code lines.
@sstur Good point! If the body has a background color then they may still have a white area where the scrollbar was.. but I supposed that is better than actual body content moving.
@anyexinglu Happy to accept a pull request (we're now open source at https://github.com/gragland/usehooks) or look at a code example in this gist. I supposed we'd just have to grab the current document.body.style
and save to state or ref, and then use that when effect cleans up.
@solutweb Ah good point. When I have some time will have to dig into what's going on there. Open to pull requests if you happen to figure it out!
in addition to width issue @anyexinglu pointed out about windows scrollbars, there is also an issue with with fixed backgrounds resizing when you overflow:hidden the scrollbars. material-ui tries to resolve this by adding padding to any element with the class mui-fixed.
Why does it use useLayoutEffect
? Seems unnecessary to me since we only use it on mount and unmount.
When using it in TypeScript, it complains that the first param doesn't match EffectCallback
, since it's returning () => string
.
Changing return () => (document.body.style.overflow = 'visible')
into return () => {document.body.style.overflow = 'visible'}
fixes this.
This currently won't work for iOS devices. You could rewrite it like this
// Create ref to bind to element that should prevent scrolling
const ref = useRef(null);
useEffect(() => {
// Add/Remove listeners for touchmove -> finding touchmove better, as it doesn't prevent other touch events like clicking links
// The checks for ref && ref.current made it more TS friendly for me
if (ref && ref.current) {
ref.current.addEventListener('touchmove', e => e.preventDefault());
}
return () => {
if (ref && ref.current) {
ref.current.removeEventListener('touchmove', e => e.preventDefault());
}
};
}, []);
useLayoutEffect(() => {
// Get original body overflow
const originalStyle = window.getComputedStyle(document.body).overflow;
// Prevent scrolling on mount
document.body.style.overflow = 'hidden';
// Re-enable scrolling when component unmounts
return () => {
return (document.body.style.overflow = originalStyle);
};
}, []); // Empty array ensures effect is only run on mount and unmount
// Return the ref, to bind it to the element;
return ref;
}````
And by binding the ref to the element that locks the scrolling, iOS devices will also respect the scroll lock
I took a shot at modifying this to make it work:
- On iOS
- When multiple "instances" of
lockBodyScroll
(modals in this case) are active
It seems to be working with my changes in my sandbox fork here: https://codesandbox.io/s/uselockbodyscroll-example-mq9rm.
Or test it on your iOS device here: https://mq9rm.csb.app/
But I'm pretty new to custom hooks (and programming, really) so I'm open to suggestions/critique 👍🏼
It's worth noting that browsers / operating systems that use a non-hiding scrollbar (I think most versions of Windows) will show/hide the scrollbar when you change the body overflow from hidden to visible.
It's a tricky one to work around, but in the past I had some luck with first measuring the width/height of the body and then fixing that size before changing the overflow. That prevents the change of layout when you enable/disable scrolling.