Your modal is open, centered, perfect. Then someone flicks the overlay and the page behind it scrolls away underneath. Everyone's first fix is the same three lines:
useEffect(() => {
document.body.style.overflow = open ? "hidden" : "";
}, [open]);
It works on your laptop. Then the bug reports arrive:
-
On iPhone the page still moves. iOS Safari rubber-band scrolls the document by touch even with
overflow: hiddenon<body>. -
Something else got wiped.
""isn't necessarily what was there before — you just erased whatever your design system or CSS-in-JS had set inline. -
Two overlays, one frozen page. A drawer and a lightbox both own
body.style.overflow; close them in the wrong order and the page never scrolls again. - The layout jumps the instant the desktop scrollbar disappears.
useScrollLock from @reactuses/core is those three lines with the hard parts handled: it restores the exact inline overflow it replaced, adds a touchmove guard on iOS that still lets your modal's own content scroll, exposes the lock as React state you can render off, and works on any element — not just <body>. This post covers what it actually does line by line, why overflow: hidden is not enough on iOS, how it compares to the position: fixed and body:has(dialog[open]) approaches, and the six gotchas that show up in real apps.
Quick Start
npm install @reactuses/core
import { useScrollLock } from "@reactuses/core";
import { useEffect } from "react";
function Modal({ open, onClose, children }: ModalProps) {
// a getter, not `document.body` — see the SSR gotcha below
const [, setLocked] = useScrollLock(() => document.body);
useEffect(() => {
setLocked(open);
return () => setLocked(false); // release even if we unmount while open
}, [open, setLocked]);
if (!open) return null;
return (
<div className="overlay" onClick={onClose}>
<div className="sheet" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>
);
}
The signature:
const [locked, setLocked] = useScrollLock(target, initialState?)
-
target— the element whose scrolling you're locking. Accepts an element, aRefObject, or a getter() => element. Resolved lazily, on every call. -
initialState— start locked. Defaults tofalse, and you should keep it that way (gotcha 3). -
Returns
[locked, setLocked].lockedis real state;setLockedis identity-stable, so it's safe in a dependency array or as a prop.
What useScrollLock Actually Does
The core of it, condensed from the source:
const [locked, setLocked] = useState(initialState);
const initialOverflowRef = useRef<CSSStyleDeclaration["overflow"]>("scroll");
useEffect(() => {
const element = getTargetElement(target);
if (element) {
initialOverflowRef.current = element.style.overflow; // remember what we're replacing
if (locked) element.style.overflow = "hidden";
}
}, [locked, target]);
const lock = useEvent(() => {
const element = getTargetElement(target);
if (!element || locked) return;
if (isIOS) element.addEventListener("touchmove", preventDefault, { passive: false });
setLocked(true);
});
const unlock = useEvent(() => {
const element = getTargetElement(target);
if (!element || !locked) return;
if (isIOS) element.removeEventListener("touchmove", preventDefault);
element.style.overflow = initialOverflowRef.current; // restore, don't clobber
setLocked(false);
});
Four decisions in there are worth naming, because they're exactly where hand-rolled versions differ:
-
The lock is state, not a fire-and-forget side effect.
lockedis a realuseStatevalue, so the same boolean that drives the style can drive youraria-hidden, your class names, your Esc handler. -
It restores the inline value it replaced, not
"". If something had setoverflow: overlayinline, that's what comes back. -
The target is resolved lazily through
getTargetElement, which returnsundefinedwhen there is nowindow. Nothing touches the DOM on the server. -
Only iOS gets a
touchmoveguard. Which is the genuinely interesting part.
Why overflow: hidden Isn't Enough on iOS
overflow: hidden on the scrolling element is the correct, spec-blessed way to stop scrolling — and iOS Safari has never fully honored it on <body>. Touch drags still rubber-band the document. The only reliable stop is to cancel the gesture itself:
element.addEventListener("touchmove", preventDefault, { passive: false });
passive: false is mandatory here, not decoration. Browsers register touch listeners on document-level targets as passive by default, and a passive listener's preventDefault() is ignored with a console warning — your lock would silently do nothing.
But a blanket preventDefault on touchmove breaks the thing you actually wanted: scrolling inside the modal. So the handler asks a question before cancelling:
function checkOverflowScroll(ele: Element): boolean {
const style = window.getComputedStyle(ele);
if (
style.overflowX === "scroll" || style.overflowY === "scroll"
|| (style.overflowX === "auto" && ele.clientWidth < ele.scrollWidth)
|| (style.overflowY === "auto" && ele.clientHeight < ele.scrollHeight)
) return true;
const parent = ele.parentNode as Element;
if (!parent || parent.tagName === "BODY") return false;
return checkOverflowScroll(parent);
}
Walk up from event.target; if any ancestor is genuinely scrollable — overflow: scroll, or overflow: auto with content that actually overflows right now — let the gesture through untouched. Otherwise cancel it. Two nice properties fall out of that:
- An
overflow: autocontainer whose content currently fits is not scrollable, so it gets locked — correctly. Add enough content and it starts scrolling again with no code change. - Multi-touch is excluded (
if (e.touches.length > 1) return true, before anypreventDefault), so pinch-to-zoom keeps working. Killing zoom inside a modal is an accessibility regression, and this sidesteps it.
useScrollLock vs the Other Four Approaches
| Approach | Stops iOS rubber-band | Keeps inner scroll | Keeps scroll position | Cost |
|---|---|---|---|---|
body.style.overflow = "hidden" by hand |
❌ | ✅ | ✅ | clobbers the inline style, never restores it |
body:has(dialog[open]) { overflow: hidden } |
❌ | ✅ | ✅ | zero JS — but the same iOS hole |
body { position: fixed; top: -scrollY } |
✅ | ✅ | only if you save and restore it yourself | takes <body> out of flow: position: fixed children re-anchor, scroll anchoring and scroll-behavior: smooth get strange |
overscroll-behavior: contain on the dialog and ::backdrop
|
✅ (Chrome 144+) | ✅ | ✅ | cleanest of all, where it's supported — and only for <dialog>
|
useScrollLock |
✅ | ✅ | ✅ | ~40 lines of JS behind one hook call |
One thing that trips people up: <dialog>.showModal() makes the rest of the document inert — clicks and Tab can't reach it — but it does not reliably block scrolling, particularly by touch on mobile. Inertness and scroll-locking are separate problems, and the browser only solves the first one for you.
And a complement rather than an alternative: overscroll-behavior: contain on your inner scroller stops scroll chaining — the inner list hitting its end and handing the gesture to the page. That's worth adding regardless of how you lock, but on its own it doesn't stop a drag that started on the backdrop.
Patterns
1. Declare the lock, don't toggle it
The Quick Start example is the pattern to internalize. Instead of calling setLocked(true) in your open handler and setLocked(false) in your close handler — two places to forget, plus every early-return path in between — bind the lock to the state that already describes the modal:
useEffect(() => {
setLocked(open);
return () => setLocked(false);
}, [open, setLocked]);
Now the lock can't drift out of sync with the UI, and the cleanup covers the case the imperative version always misses: a route change that unmounts the modal while it's open.
Combined with useDisclosure for the open/close state itself:
import { useDisclosure, useScrollLock } from "@reactuses/core";
import { useEffect } from "react";
function Drawer({ children }: { children: React.ReactNode }) {
const { isOpen, onOpen, onClose } = useDisclosure();
const [locked, setLocked] = useScrollLock(() => document.body);
useEffect(() => {
setLocked(isOpen);
return () => setLocked(false);
}, [isOpen, setLocked]);
return (
<>
<button onClick={onOpen}>Menu</button>
<main aria-hidden={locked}>{/* page content */}</main>
{isOpen && (
<aside className="drawer">
{children}
<button onClick={onClose}>Close</button>
</aside>
)}
</>
);
}
Note the locked half of the tuple earning its keep: one boolean drives both the style and the accessibility state, so they cannot disagree. (On React 19 you can use the same value for inert.)
2. Lock a scroll container, not the document
Plenty of apps don't scroll the document at all — the shell is height: 100vh; overflow: auto and everything scrolls inside a div. overflow: hidden on <body> does exactly nothing there, which is a confusing afternoon if you don't know it. Point the hook at the real scroller:
function Shell({ children }: { children: React.ReactNode }) {
const scroller = useRef<HTMLDivElement>(null);
const [, setLocked] = useScrollLock(scroller);
return (
<div ref={scroller} style={{ height: "100vh", overflow: "auto" }}>
{children}
</div>
);
}
Same hook, same tuple. This is why target is required rather than defaulting to document.body: the library can't know which element is your scroll root.
3. Lock during a drag
Touch-dragging a slider, a sortable list, or a custom carousel scrolls the page unless something stops it — and a touchmove guard is exactly the right tool:
const [, setLocked] = useScrollLock(() => document.body);
<div
onPointerDown={() => setLocked(true)}
onPointerUp={() => setLocked(false)}
onPointerCancel={() => setLocked(false)}
/>
onPointerCancel matters: the browser can steal the pointer mid-gesture, and without it you'd leave the page locked. If you're building the drag itself rather than wiring one up, useDraggable already handles the pointer bookkeeping.
Gotchas Worth Knowing
1. The lock is a style, not a lifecycle
The lock is an inline overflow: hidden written onto an element the hook doesn't own, so something has to put it back. Since @reactuses/core v6.5.3 the hook does that itself when the owning component unmounts: it restores the exact inline value it replaced and detaches the iOS touchmove guard, so a route change with the modal still open can no longer leave the page frozen. On v6.5.2 and earlier it didn't — worth knowing if you're pinned to an older version, because on iOS the leftover passive: false listener kills touch scrolling for the rest of the session, not just the style.
Unmounting is only half of it. The other half — the modal closing while the component stays mounted — is yours either way, which is exactly why the pattern above binds setLocked to open with a cleanup instead of toggling it from two handlers:
useEffect(() => {
setLocked(open);
return () => setLocked(false);
}, [open, setLocked]);
Think of the setter as owning a style you borrowed. Every borrow needs a return.
2. One owner per element
Two hook instances locking the same element is the subtlest failure mode, because each keeps its own memory of the original overflow:
A.lock() → overflow: hidden (A remembered "auto")
B.lock() → overflow: hidden (B remembered "hidden" 😬)
A.unlock() → overflow: auto (page scrolls, though B still thinks it's locked)
B.unlock() → overflow: hidden (page is now stuck, with nothing open)
Nothing is going to save you here — this is inherent to "save the old value, put it back" and applies to every hand-rolled lock and most libraries. The answer is architectural: one lock owner per element. Put the useScrollLock(() => document.body) call in your layout, provider, or store, and let modals ask it to lock rather than each carrying its own.
3. initialState: true skips the iOS guard
useScrollLock(target, true) applies overflow: hidden from the first commit — but the touchmove listener is only attached inside lock(), which never ran. So a page that starts locked is still rubber-band-scrollable on iOS. Start false and flip it:
const [, setLocked] = useScrollLock(() => document.body);
useEffect(() => { setLocked(true); }, [setLocked]); // locked from mount, guard included
4. Desktop layout shift
Hiding the scrollbar reclaims ~15px and the entire page shifts sideways. That's not the hook's job to fix, and it's one CSS line:
html { scrollbar-gutter: stable; }
5. Pass a getter, not document.body, for SSR
useScrollLock(document.body) evaluates document.body during render, which throws on the server before the hook gets a chance to be careful. () => document.body (or a ref) is only read inside effects and handlers, where getTargetElement already bails out without a window:
const [, setLocked] = useScrollLock(() => document.body); // ✅ SSR-safe
const [, setLocked] = useScrollLock(document.body); // ❌ crashes on the server
The same rule applies to every hook in the library that takes an element target, and it's the single most common SSR mistake in Next.js and Remix apps.
6. hidden stops gestures, not programmatic scrolling
An overflow: hidden box is still scrollable via scrollTop, scrollTo, scrollIntoView — and, crucially, by the browser scrolling a newly focused element into view. If focus escapes to a link behind your modal, your "locked" page will scroll to it. Scroll locking and focus trapping are two halves of the same feature; ship both.
When Not to Use useScrollLock
-
You only need to stop an inner scroller from chaining to the page →
overscroll-behavior: containin CSS, no JavaScript at all. -
You're using
<dialog>and can require Chrome 144+ →overscroll-behavior: containon the dialog and its::backdropis less code than any hook. -
You want to scroll to something →
useScrollIntoView, or the native one-liner — yesterday's post on scrollIntoView with useRef covers both. -
You want to read or react to scroll position →
useScrolloruseWindowScroll. -
You want a genuinely immersive, chrome-free view →
useFullscreeninstead of locking a scroll container. -
You're loading more rows as the user scrolls →
useInfiniteScroll; the last thing you want there is a lock.
Takeaways
-
overflow: hiddenis the right mechanism on desktop and an incomplete one on iOS Safari, where only cancellingtouchmove(withpassive: false) actually stops the document from rubber-banding. -
useScrollLockpairs that guard with a scroll-aware ancestor check, so the page can't move while your modal's own content still scrolls — and multi-touch zoom survives. - It restores the exact inline
overflowit replaced, exposes the lock as state you can render off, and works on any element, which is what you need when your app scrolls inside a div rather than the document. - Bind the lock to the state that describes your UI (
setLocked(open)plus a cleanup), keep one owner per element, startinitialStateatfalse, pass a getter for SSR, and addscrollbar-gutter: stablefor the desktop shift. - Scroll locking is half a modal. Trap focus too, or
hiddenwill still scroll when something behind the overlay takes focus.
useScrollLock, useDisclosure, useScrollIntoView, and 110+ other SSR-safe, TypeScript-first hooks live in @reactuses/core — one install, tree-shakeable, no dependencies to babysit.
npm install @reactuses/core
Top comments (0)