DEV Community

ReactUse
ReactUse

Posted on Originally published at reactuse.com

React useScrollLock Hook: Lock Body Scroll for Modals (2026)

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]);
Enter fullscreen mode Exit fullscreen mode

It works on your laptop. Then the bug reports arrive:

  1. On iPhone the page still moves. iOS Safari rubber-band scrolls the document by touch even with overflow: hidden on <body>.
  2. 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.
  3. 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.
  4. 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
Enter fullscreen mode Exit fullscreen mode
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>
  );
}
Enter fullscreen mode Exit fullscreen mode

The signature:

const [locked, setLocked] = useScrollLock(target, initialState?)
Enter fullscreen mode Exit fullscreen mode
  • 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.

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);
});
Enter fullscreen mode Exit fullscreen mode

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.

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 });
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

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]);
Enter fullscreen mode Exit fullscreen mode

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>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)}
/>
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 pageoverscroll-behavior: contain in CSS, no JavaScript at all.
  • You're using <dialog> and can require Chrome 144+overscroll-behavior: contain on the dialog and its ::backdrop is less code than any hook.
  • You want to scroll to somethinguseScrollIntoView, or the native one-liner — yesterday's post on scrollIntoView with useRef covers both.
  • You want to read or react to scroll positionuseScroll or useWindowScroll.
  • You want a genuinely immersive, chrome-free viewuseFullscreen instead of locking a scroll container.
  • You're loading more rows as the user scrollsuseInfiniteScroll; the last thing you want there is a lock.

Takeaways

  • overflow: hidden is the right mechanism on desktop and an incomplete one on iOS Safari, where only cancelling touchmove (with passive: false) actually stops the document from rubber-banding.
  • useScrollLock pairs 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 overflow it 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, start initialState at false, pass a getter for SSR, and add scrollbar-gutter: stable for the desktop shift.
  • Scroll locking is half a modal. Trap focus too, or hidden will 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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)