A scroll lock freezes the page behind a modal, drawer, or menu so the content underneath cannot move while the overlay is open. useLockBodyScroll is a React hook that does this by setting document.body.style.overflow to hidden while it is active, and restoring the page's original overflow when the component closes or unmounts. The interesting part is not the one-liner — it is what happens when two of them run at once.
function useLockBodyScroll(locked?: boolean): void;
// locked defaults to true. While it is true, the page cannot scroll.
// The hook returns nothing — it is a side effect tied to the component's life.
// A modal locks the page while it is open and unlocks when it unmounts.
function Modal({ children }) {
useLockBodyScroll(); // locks on mount, restores on unmount
return <div className="backdrop">{children}</div>;
}
// Toggle without unmounting: lock only while `open` is true.
function Drawer({ open }) {
useLockBodyScroll(open); // true -> page frozen; false -> page scrolls
return open ? <aside className="drawer">...</aside> : null;
}
'' is a bug.locked argument is optional. Default it to true. A caller that passes a boolean can turn the lock on and off without mounting or unmounting the component.document.body.style.overflow, which is fully supported in the test environment. You will not need to fake time or mock element geometry.You'll write a two-line effect that everyone gets right — and then discover it is wrong the moment a second copy of it runs.
A modal opens. You don't want the page behind it to scroll — the user should be trapped in the dialog, not able to nudge the article underneath. The mechanism is one line: set document.body.style.overflow to hidden and the body stops scrolling. Close the modal, put overflow back, done.
That is genuinely the whole feature for one modal. The question only gets interesting when there are two of them at once — a dialog that opens a confirmation dialog, a slide-out drawer with a modal on top. Now two components each think they own the body's overflow, and the obvious code hands the first one to leave the power to unlock a page the second one still needs frozen.
Locking the body does not remove the page or hide it. It freezes the scroll layer — the body keeps its content, but that content can no longer move. The overlay floats above, with its own scroll, and everything behind it holds still.
So the hook's job is small and mechanical: while it is active, the body's overflow is hidden; when it stops being active, the body goes back to whatever it was. The trap is entirely in that last clause — whatever it was, and when it stops.
Save the current overflow, set hidden, and restore the saved value on cleanup. Run it in a layout effect so the lock lands before the browser paints — no flash of a scrollable page as the modal opens.
const { useLayoutEffect } = require('react');
function useLockBodyScroll(locked = true) {
useLayoutEffect(() => {
if (!locked) return;
const original = document.body.style.overflow; // save whatever was there
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = original; // put it back on cleanup
};
}, [locked]);
}
For one modal this is correct, and it even restores a pre-existing value rather than blindly clearing to empty. It falls apart with two. Mount hook A: it saves '' and sets hidden. Mount hook B while A is still open: it reads the body's overflow now, which is hidden (A set it), saves that, and sets hidden again. Close A first: its cleanup restores A's saved value, '' — the page is unlocked, even though B is still open and still wants it frozen. Then close B: its cleanup restores B's saved value, hidden — now the page is stuck locked forever. Each instance was bookkeeping in isolation, and neither one knew the other existed.
The fix is to stop letting instances act alone. One page has one body, so one shared counter decides when to freeze and when to thaw: freeze on the way up through zero, restore on the way back down to zero, and do nothing in between.
const { useLayoutEffect } = require('react');
// One page has one <body>, so a single counter and a single saved value
// coordinate EVERY instance of the hook. This lives at module scope on purpose
// — it is shared across every component that calls useLockBodyScroll.
let lockCount = 0;
let originalOverflow = '';
function lock() {
// Only the 0 -> 1 transition touches the DOM. Capture the body's real
// overflow ONCE, at that moment, before we overwrite it — so unlocking can
// hand back the exact value the page had, not a guess.
if (lockCount === 0) {
originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
}
lockCount += 1;
}
function unlock() {
// A stray unlock must never drive the count below zero — that would then take
// two locks to re-freeze the page and leave it scrollable in between.
if (lockCount === 0) return;
lockCount -= 1;
// Only the 1 -> 0 transition restores. While anyone else still holds a lock,
// the body stays frozen.
if (lockCount === 0) {
document.body.style.overflow = originalOverflow;
}
}
function useLockBodyScroll(locked = true) {
useLayoutEffect(() => {
// No DOM on the server, nothing to lock. (useLayoutEffect doesn't run there
// anyway — this just keeps the hook safe in any non-browser environment.)
if (typeof document === 'undefined') return;
if (!locked) return;
lock();
return unlock; // runs on unmount, or before the next run when `locked` flips
}, [locked]);
}
module.exports = { useLockBodyScroll };
Two things moved out of the hook and up to module scope: the count of how many components currently want the lock, and the one saved overflow value. The hook no longer decides anything about the DOM — it just says "I want a lock" on mount and "I'm done" on cleanup, and the lock/unlock pair translates those into the two DOM writes that actually matter: the first freeze and the last restore.
The counter is only half of it. The other half is that saved value, and getting it right means capturing it once, at the first lock, not on every instance.
If you captured the original inside every instance instead — the naive version's mistake — the second lock would read hidden and remember it as "the original," and the page would never come back. Capturing only at 0 -> 1 means originalOverflow holds the value from before any lock existed, which is the only value that is actually correct to restore.
Trace the exact sequence the naive version fails. Say the page starts at overflow: ''.
lockCount is 0, so lock() captures originalOverflow = '' and sets the body to hidden. Count is now 1. The page is frozen.lockCount is 1, not 0, so lock() touches nothing — it just bumps the count to 2. originalOverflow still holds '', the value from before any lock. The page stays frozen.unlock(). Count drops to 1. It is not 0, so nothing is restored — the page stays frozen, which is exactly right, because B still needs it. This is the step the naive version got wrong.unlock(). Count drops to 0, so unlock() restores document.body.style.overflow = originalOverflow, which is ''. The page scrolls again.The naive version reached step 3 and restored A's own saved value, unlocking the page under B's feet; here, A closing is just a decrement, and only the final close touches the DOM.
Two popular hooks solve this, and they make opposite trade-offs — both worth knowing.
react-use's useLockBodyScroll is this solution, generalized. It keeps a module-level Map keyed by the body element, and each entry is a { counter, initialOverflow } pair — the same counter and saved value as above, one set per body so it can also lock an iframe's body or an arbitrary element's nearest body. It captures initialOverflow when a body's counter first goes positive and restores it only when that counter returns to zero: exactly the 0 -> 1 / 1 -> 0 rule. Its signature is useLockBodyScroll(locked = true, elementRef?), so the locked argument and an optional target are first-class. One detail worth stealing: on iOS it does not set overflow at all (that doesn't stop touch scrolling — see below) and instead attaches a non-passive touchmove listener that calls preventDefault().
usehooks-ts's useScrollLock deliberately does something this solution does not, and deliberately skips something this solution does. It saves and restores state in per-instance refs (target, originalStyle) with no module-level counter — so two useScrollLock calls on document.body each bookkeep on their own, which is precisely the nested bug above. What it adds instead is layout-shift compensation: before setting overflow: hidden it measures the scrollbar as offsetWidth - scrollWidth and adds that to the body's padding-right, so the page doesn't jump wider when the scrollbar disappears. It returns { isLocked, lock, unlock } with autoLock, lockTarget, and widthReflow options. It is the better single-lock experience and the worse multi-lock one — a real, citable difference, not a bug.
'' instead of the saved value. If the page set overflow for its own reasons (a scroll container, a clip layout), clearing to empty on unlock silently deletes that. Fix: capture the real value on lock and write that back — the pre-existing-value test pins this.hidden and the page is stuck. Fix: a shared count; only 0 -> 1 freezes and only 1 -> 0 restores.document.body.style.overflow at its own mount, the second reads hidden and remembers it as "the original," so the page never comes back. Fix: capture only at the 0 -> 1 transition.unlock() drives the count below zero, so the next real lock leaves it at zero-but-not-frozen. Fix: guard unlock() with if (lockCount === 0) return.useEffect instead of useLayoutEffect. With useEffect the browser can paint one frame of a still-scrollable page before the lock lands — a visible flicker as the overlay opens. Fix: useLayoutEffect runs synchronously before paint.document.body.style.overflow between cases — see this question's test hygiene notes.overflow: hidden. On iOS Safari, overflow: hidden on the body does not stop touch scrolling — the page still rubber-bands behind the modal. The usual fix is position: fixed with top: -scrollY on the body, then window.scrollTo(0, scrollY) on unlock so the page doesn't jump to the top. react-use instead attaches a non-passive touchmove preventDefault listener. Either is more than the tests require.window.innerWidth - document.documentElement.clientWidth and add it to the body's padding-right while locked, as usehooks-ts does, to hold the layout still.Map keyed by the element (react-use's approach) — to freeze a scrollable panel instead of the whole page.useIsomorphicLayoutEffect. useLayoutEffect logs a warning during server rendering because it can't run there. Swapping in the isomorphic variant (a layout effect in the browser, a plain effect on the server) silences it without changing behavior.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A scroll lock freezes the page behind a modal, drawer, or menu so the content underneath cannot move while the overlay is open. useLockBodyScroll is a React hook that does this by setting document.body.style.overflow to hidden while it is active, and restoring the page's original overflow when the component closes or unmounts. The interesting part is not the one-liner — it is what happens when two of them run at once.
function useLockBodyScroll(locked?: boolean): void;
// locked defaults to true. While it is true, the page cannot scroll.
// The hook returns nothing — it is a side effect tied to the component's life.
// A modal locks the page while it is open and unlocks when it unmounts.
function Modal({ children }) {
useLockBodyScroll(); // locks on mount, restores on unmount
return <div className="backdrop">{children}</div>;
}
// Toggle without unmounting: lock only while `open` is true.
function Drawer({ open }) {
useLockBodyScroll(open); // true -> page frozen; false -> page scrolls
return open ? <aside className="drawer">...</aside> : null;
}
'' is a bug.locked argument is optional. Default it to true. A caller that passes a boolean can turn the lock on and off without mounting or unmounting the component.document.body.style.overflow, which is fully supported in the test environment. You will not need to fake time or mock element geometry.You'll write a two-line effect that everyone gets right — and then discover it is wrong the moment a second copy of it runs.
A modal opens. You don't want the page behind it to scroll — the user should be trapped in the dialog, not able to nudge the article underneath. The mechanism is one line: set document.body.style.overflow to hidden and the body stops scrolling. Close the modal, put overflow back, done.
That is genuinely the whole feature for one modal. The question only gets interesting when there are two of them at once — a dialog that opens a confirmation dialog, a slide-out drawer with a modal on top. Now two components each think they own the body's overflow, and the obvious code hands the first one to leave the power to unlock a page the second one still needs frozen.
Locking the body does not remove the page or hide it. It freezes the scroll layer — the body keeps its content, but that content can no longer move. The overlay floats above, with its own scroll, and everything behind it holds still.
So the hook's job is small and mechanical: while it is active, the body's overflow is hidden; when it stops being active, the body goes back to whatever it was. The trap is entirely in that last clause — whatever it was, and when it stops.
Save the current overflow, set hidden, and restore the saved value on cleanup. Run it in a layout effect so the lock lands before the browser paints — no flash of a scrollable page as the modal opens.
const { useLayoutEffect } = require('react');
function useLockBodyScroll(locked = true) {
useLayoutEffect(() => {
if (!locked) return;
const original = document.body.style.overflow; // save whatever was there
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = original; // put it back on cleanup
};
}, [locked]);
}
For one modal this is correct, and it even restores a pre-existing value rather than blindly clearing to empty. It falls apart with two. Mount hook A: it saves '' and sets hidden. Mount hook B while A is still open: it reads the body's overflow now, which is hidden (A set it), saves that, and sets hidden again. Close A first: its cleanup restores A's saved value, '' — the page is unlocked, even though B is still open and still wants it frozen. Then close B: its cleanup restores B's saved value, hidden — now the page is stuck locked forever. Each instance was bookkeeping in isolation, and neither one knew the other existed.
The fix is to stop letting instances act alone. One page has one body, so one shared counter decides when to freeze and when to thaw: freeze on the way up through zero, restore on the way back down to zero, and do nothing in between.
const { useLayoutEffect } = require('react');
// One page has one <body>, so a single counter and a single saved value
// coordinate EVERY instance of the hook. This lives at module scope on purpose
// — it is shared across every component that calls useLockBodyScroll.
let lockCount = 0;
let originalOverflow = '';
function lock() {
// Only the 0 -> 1 transition touches the DOM. Capture the body's real
// overflow ONCE, at that moment, before we overwrite it — so unlocking can
// hand back the exact value the page had, not a guess.
if (lockCount === 0) {
originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
}
lockCount += 1;
}
function unlock() {
// A stray unlock must never drive the count below zero — that would then take
// two locks to re-freeze the page and leave it scrollable in between.
if (lockCount === 0) return;
lockCount -= 1;
// Only the 1 -> 0 transition restores. While anyone else still holds a lock,
// the body stays frozen.
if (lockCount === 0) {
document.body.style.overflow = originalOverflow;
}
}
function useLockBodyScroll(locked = true) {
useLayoutEffect(() => {
// No DOM on the server, nothing to lock. (useLayoutEffect doesn't run there
// anyway — this just keeps the hook safe in any non-browser environment.)
if (typeof document === 'undefined') return;
if (!locked) return;
lock();
return unlock; // runs on unmount, or before the next run when `locked` flips
}, [locked]);
}
module.exports = { useLockBodyScroll };
Two things moved out of the hook and up to module scope: the count of how many components currently want the lock, and the one saved overflow value. The hook no longer decides anything about the DOM — it just says "I want a lock" on mount and "I'm done" on cleanup, and the lock/unlock pair translates those into the two DOM writes that actually matter: the first freeze and the last restore.
The counter is only half of it. The other half is that saved value, and getting it right means capturing it once, at the first lock, not on every instance.
If you captured the original inside every instance instead — the naive version's mistake — the second lock would read hidden and remember it as "the original," and the page would never come back. Capturing only at 0 -> 1 means originalOverflow holds the value from before any lock existed, which is the only value that is actually correct to restore.
Trace the exact sequence the naive version fails. Say the page starts at overflow: ''.
lockCount is 0, so lock() captures originalOverflow = '' and sets the body to hidden. Count is now 1. The page is frozen.lockCount is 1, not 0, so lock() touches nothing — it just bumps the count to 2. originalOverflow still holds '', the value from before any lock. The page stays frozen.unlock(). Count drops to 1. It is not 0, so nothing is restored — the page stays frozen, which is exactly right, because B still needs it. This is the step the naive version got wrong.unlock(). Count drops to 0, so unlock() restores document.body.style.overflow = originalOverflow, which is ''. The page scrolls again.The naive version reached step 3 and restored A's own saved value, unlocking the page under B's feet; here, A closing is just a decrement, and only the final close touches the DOM.
Two popular hooks solve this, and they make opposite trade-offs — both worth knowing.
react-use's useLockBodyScroll is this solution, generalized. It keeps a module-level Map keyed by the body element, and each entry is a { counter, initialOverflow } pair — the same counter and saved value as above, one set per body so it can also lock an iframe's body or an arbitrary element's nearest body. It captures initialOverflow when a body's counter first goes positive and restores it only when that counter returns to zero: exactly the 0 -> 1 / 1 -> 0 rule. Its signature is useLockBodyScroll(locked = true, elementRef?), so the locked argument and an optional target are first-class. One detail worth stealing: on iOS it does not set overflow at all (that doesn't stop touch scrolling — see below) and instead attaches a non-passive touchmove listener that calls preventDefault().
usehooks-ts's useScrollLock deliberately does something this solution does not, and deliberately skips something this solution does. It saves and restores state in per-instance refs (target, originalStyle) with no module-level counter — so two useScrollLock calls on document.body each bookkeep on their own, which is precisely the nested bug above. What it adds instead is layout-shift compensation: before setting overflow: hidden it measures the scrollbar as offsetWidth - scrollWidth and adds that to the body's padding-right, so the page doesn't jump wider when the scrollbar disappears. It returns { isLocked, lock, unlock } with autoLock, lockTarget, and widthReflow options. It is the better single-lock experience and the worse multi-lock one — a real, citable difference, not a bug.
'' instead of the saved value. If the page set overflow for its own reasons (a scroll container, a clip layout), clearing to empty on unlock silently deletes that. Fix: capture the real value on lock and write that back — the pre-existing-value test pins this.hidden and the page is stuck. Fix: a shared count; only 0 -> 1 freezes and only 1 -> 0 restores.document.body.style.overflow at its own mount, the second reads hidden and remembers it as "the original," so the page never comes back. Fix: capture only at the 0 -> 1 transition.unlock() drives the count below zero, so the next real lock leaves it at zero-but-not-frozen. Fix: guard unlock() with if (lockCount === 0) return.useEffect instead of useLayoutEffect. With useEffect the browser can paint one frame of a still-scrollable page before the lock lands — a visible flicker as the overlay opens. Fix: useLayoutEffect runs synchronously before paint.document.body.style.overflow between cases — see this question's test hygiene notes.overflow: hidden. On iOS Safari, overflow: hidden on the body does not stop touch scrolling — the page still rubber-bands behind the modal. The usual fix is position: fixed with top: -scrollY on the body, then window.scrollTo(0, scrollY) on unlock so the page doesn't jump to the top. react-use instead attaches a non-passive touchmove preventDefault listener. Either is more than the tests require.window.innerWidth - document.documentElement.clientWidth and add it to the body's padding-right while locked, as usehooks-ts does, to hold the layout still.Map keyed by the element (react-use's approach) — to freeze a scrollable panel instead of the whole page.useIsomorphicLayoutEffect. useLayoutEffect logs a warning during server rendering because it can't run there. Swapping in the isomorphic variant (a layout effect in the browser, a plain effect on the server) silences it without changing behavior.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.