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:
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: hidden on . 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 . 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.
The signature: target — the element whose scrolling you're locking. Accepts an element, a RefObject, or a getter () => element. Resolved lazily, on every call. initialState — start locked. Defaults to false, and you should keep it that way (gotcha 3). Returns [locked, setLocked]. locked is real state; setLocked is identity-stable, so it's safe in a dependency array or as a prop.
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. locked is a real useState value, so the same boolean that drives the style can drive your aria-hidden, your class names, your Esc handler. It restores the inline value it replaced, not "". If something had set overflow: overlay inline, that's what comes back. The target is resolved lazily through getTargetElement, which returns undefined when there is no window. Nothing touches the DOM on the server. Only iOS gets a touchmove guard. Which is the genuinely interesting part.
overflow: hidden on the scrolling element is the correct, spec-blessed way to stop scrolling — and iOS Safari has never fully honored it on . Touch drags still rubber-band the document. The only reliable stop is to cancel the gesture itself:
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:
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: auto container 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 any preventDefault), so pinch-to-zoom keeps working. Killing zoom inside a modal is an accessibility regression, and this sidesteps it.
| 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 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 | | useScrollLock | ✅ | ✅ | ✅ | ~40 lines of JS behind one hook call |
One thing that trips people up: .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.
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:
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.
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.) 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 does exactly nothing there, which is a confusing afternoon if you don't know it. Point the hook at the real scroller:
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. 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:
