npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@usefy/use-scroll-lock

v0.25.1

Published

A React hook to lock body scroll for modals, drawers, and menus — with iOS handling, a nested lock counter, and scroll-position restore

Readme


Overview

useScrollLock is part of the @usefy ecosystem — a collection of production-ready, TypeScript-first, SSR-safe React hooks. It locks the page (document.body) scroll — the reliable way to keep the background from scrolling behind a modal, drawer, or menu — and cleanly restores it.

Locking sets overflow: hidden on the body and adds padding-right equal to the scrollbar width so the content never jumps sideways when the scrollbar disappears. On iOS/Safari — where overflow: hidden does not stop touch scrolling — it instead pins the body with position: fixed and restores the scroll position with window.scrollTo on unlock.

A single module-level reference counter is shared by every instance, so N stacked locks (e.g. two nested modals) apply the body styles once and only restore the body when the last lock is released.

Features

  • Nested / reference-counted — a shared counter means multiple simultaneous locks apply the body styles once and restore only on the last release
  • iOS-awareposition: fixed body fix plus scroll-position restore on Apple touch devices (where overflow: hidden isn't enough)
  • No layout shift — compensates for the vanished scrollbar with matching padding-right
  • Idempotent per instance — calling lock() twice never double-counts against the shared counter
  • StrictMode / concurrent-safe — an unmount always releases a still-held lock; React 18's double mount never leaks
  • Original styles preserved — the exact inline body styles (and scroll position) are captured on lock and written back on unlock
  • enabled convenience — optionally hand the lock's lifetime to a boolean
  • SSR-safe — no window/document access on the server; isLocked is false and lock/unlock are no-ops
  • TypeScript-first — full type inference and exported types
  • Tiny & tree-shakeable — published as its own package

Installation

# npm
npm install @usefy/use-scroll-lock

# yarn
yarn add @usefy/use-scroll-lock

# pnpm
pnpm add @usefy/use-scroll-lock

Requires React 18 or 19 (peerDependencies: "react": "^18.0.0 || ^19.0.0").

Quick Start

import { useEffect, useState } from "react";
import { useScrollLock } from "@usefy/use-scroll-lock";

function Modal() {
  const [open, setOpen] = useState(false);
  const { lock, unlock } = useScrollLock();

  useEffect(() => {
    if (open) lock();
    else unlock();
  }, [open, lock, unlock]);

  return (
    <>
      <button onClick={() => setOpen(true)}>Open modal</button>
      {open && (
        <div role="dialog" aria-modal="true">
          <button onClick={() => setOpen(false)}>Close</button>
        </div>
      )}
    </>
  );
}

API

useScrollLock(options?)

Returns { lock, unlock, isLocked } for locking the page scroll.

Options — UseScrollLockOptions

| Option | Type | Default | Description | | --------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | enabled | boolean | false | When true, hold a lock automatically for as long as it stays true (locks on mount, releases on unmount / when it flips false). Let it own the lock — don't also call lock/unlock on the same instance |

Returns — UseScrollLockReturn

| Property | Type | Description | | ---------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | lock | () => void | Acquire a lock for this instance. Stable (useCallback) and idempotent — never double-counts against the shared counter | | unlock | () => void | Release this instance's lock. Stable and idempotent. The body is only restored once the last lock across all instances is gone | | isLocked | boolean | Whether this instance currently holds a lock (the body may still be locked by another instance) |

Also exported: the isIOS and getScrollbarWidth helpers, and the UseScrollLockOptions / UseScrollLockReturn types.

Examples

Nested locks (stacked modals)

Because the lock counter is shared, a second modal opened on top of the first doesn't re-apply the body styles, and closing it doesn't unlock the page while the first is still open:

import { useScrollLock } from "@usefy/use-scroll-lock";

function Dialog({ open }: { open: boolean }) {
  // Each dialog holds its own lock; the body unlocks only when both are closed.
  useScrollLock({ enabled: open });
  return open ? <div role="dialog">…</div> : null;
}

Declarative with enabled

import { useState } from "react";
import { useScrollLock } from "@usefy/use-scroll-lock";

function Drawer() {
  const [open, setOpen] = useState(false);

  // The option owns the lock for the drawer's lifetime — no effect wiring.
  useScrollLock({ enabled: open });

  return (
    <>
      <button onClick={() => setOpen((v) => !v)}>Toggle drawer</button>
      {open && <aside>Drawer content</aside>}
    </>
  );
}

Testing

📊 View Detailed Coverage Report (GitHub Pages) — 37 tests, 100% statement coverage.

  • useScrollLock.test.ts — hook behavior (imperative lock/unlock, per-instance idempotency, nested locks across two instances, per-instance isLocked, unmount cleanup, the enabled option, and StrictMode double-mount safety)
  • scrollLockManager.test.ts — the shared reference counter (single apply/restore, scrollbar-width padding, original-style preservation, the iOS position: fixed branch + scrollTo restore, negative-count guard, SSR no-op)
  • utils.test.ts — the pure isBrowser / isIOS (iPhone, iPad, iPadOS-as-MacIntel, desktop Mac) / getScrollbarWidth helpers, including SSR guards

License

MIT © mirunamu

This package is part of the usefy monorepo.