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

@hunterliu/scroll-lock

v0.0.10

Published

A lightweight, SSR-safe scroll locking library with reference counting support. Perfect for modals, dialogs, and overlays that need to prevent background scrolling.

Readme

@hunterliu/scroll-lock

npm version npm downloads

A lightweight, SSR-safe scroll locking library with reference counting support. Perfect for modals, dialogs, and overlays that need to prevent background scrolling.

Features

  • 🔒 Reference counting - Multiple locks on the same element work properly
  • 🌐 SSR-safe - Works seamlessly in server-side rendering environments
  • 🎯 Multiple targets - Lock body, documentElement, or any HTMLElement
  • 📱 iOS support - Cancels touch scrolling on iOS, except inside elements that can still scroll
  • 📏 Scrollbar gap - Optionally keeps the layout still when the scrollbar disappears
  • 🧩 Isolated instances - createScrollLock() gives a library or widget its own lock registry
  • 🔧 TypeScript - Full type safety out of the box
  • ⚡ Lightweight - Minimal bundle size with zero dependencies
  • 🧹 Clean restoration - Properly restores original overflow styles

Installation

# ✨ Auto-detect (supports npm, yarn, pnpm, deno and bun)
npx nypm install @hunterliu/scroll-lock

Basic Usage

import {
  lockScroll,
  unlockScroll,
  isScrollLocked,
} from "@hunterliu/scroll-lock";

// Lock body scroll (default target)
lockScroll(document.body);

// Check if scroll is locked
console.log(isScrollLocked(document.body)); // true

// Unlock body scroll
unlockScroll(document.body);

console.log(isScrollLocked(document.body)); // false

Reference Counting

The library uses reference counting, so multiple lockScroll() calls require the same number of unlockScroll() calls:

lockScroll(document.body); // count: 1
lockScroll(document.body); // count: 2

unlockScroll(document.body); // count: 1 - still locked
unlockScroll(document.body); // count: 0 - now unlocked

Custom Targets

Lock scroll on specific elements:

const modal = document.querySelector(".modal");

// Lock specific element
lockScroll(modal);

// Check if scroll is locked
console.log(isScrollLocked(modal)); // true

// Unlock with same target
unlockScroll(modal);

Force Unlock

Bypass reference counting with force unlock:

lockScroll(document.body); // count: 1
lockScroll(document.body); // count: 2

// Force unlock ignores count
unlockScroll(document.body, { force: true }); // immediately unlocked

Scrollbar Gap

When the scrollbar of a locked element disappears, its content shifts by the scrollbar's width. reserveScrollBarGap keeps that room for the duration of the lock. The body gets the width as padding-right. Any other element keeps a stable scrollbar-gutter where the browser supports it, and otherwise gets the width as padding on its scrollbar's side, the left for a right-to-left element. Nothing is added when the gutter is already stable.

lockScroll(document.body, { reserveScrollBarGap: true });
// body gets padding-right: <current padding> + <scrollbar width>

unlockScroll(document.body);
// padding-right is restored

The gap is reserved by the first lock and restored by the last unlock, so nested locks never add it twice.

iOS

iOS ignores overflow: hidden on the page, so on iOS a lock cancels touchmove on the locked element instead. Touches inside an element that can still scroll in the direction of the finger, vertically or horizontally, are left alone, so lists and carousels inside a modal keep scrolling without rubber-banding the page behind it. Multi-touch gestures such as pinch to zoom are never cancelled.

Clear All Locks

Reset all scroll locks across all targets:

import { clearAllScrollLocks } from "@hunterliu/scroll-lock";

// Lock multiple targets
lockScroll(document.body);
lockScroll(document.querySelector(".modal"));
lockScroll(document.querySelector(".sidebar"));

// Clear everything
clearAllScrollLocks(); // all targets unlocked

Isolated Instances

The functions exported from the package share one lock registry. createScrollLock() returns the same functions bound to a registry of their own, so a library or widget can count and clear its locks without touching the ones the app made:

import { createScrollLock, lockScroll } from "@hunterliu/scroll-lock";

const drawerLocks = createScrollLock();
const drawer = document.querySelector<HTMLElement>(".drawer");

lockScroll(document.body); // the app's own lock
drawerLocks.lockScroll(drawer);

drawerLocks.clearAllScrollLocks(); // unlocks the drawer, body stays locked

Instances do not know about each other, so lock a given element through one instance only: when two instances lock the same element, one releasing it can make it scrollable while the other still counts it as locked.

API Reference

lockScroll(target, options?)

Lock scroll on target element.

function lockScroll(
  target: ScrollLockTarget,
  options?: LockScrollOptions,
): LockState | undefined;

Parameters:

  • target: ScrollLockTarget - Element to lock (defaults to document.body if null/undefined)
  • options?: LockScrollOptions - Optional configuration object
    • reserveScrollBarGap?: boolean - Keep the room of the disappearing scrollbar, see Scrollbar Gap (default: false)

Returns:

  • LockState | undefined - The lock state object, or undefined if not in browser environment

unlockScroll(target, options?)

Unlock scroll on target element.

function unlockScroll(
  target: ScrollLockTarget,
  options?: {
    force?: boolean;
  },
): LockState | undefined;

Parameters:

  • target: ScrollLockTarget - Element to unlock (defaults to document.body if null/undefined)
  • options? - Optional configuration object
    • force?: boolean - Bypass reference counting (default: false)

Returns:

  • LockState | undefined - The lock state object, or undefined if not in browser environment

isScrollLocked(target)

Check if target is currently locked.

function isScrollLocked(target: ScrollLockTarget): boolean;

Parameters:

  • target: ScrollLockTarget - Element to check (defaults to document.body if null/undefined)

Returns:

  • boolean - true if the element is locked, false otherwise

clearAllScrollLocks()

Clear all scroll locks on all targets.

function clearAllScrollLocks(): void;

createScrollLock()

Create an isolated instance with its own lock registry.

function createScrollLock(): {
  lockScroll: typeof lockScroll;
  unlockScroll: typeof unlockScroll;
  isScrollLocked: typeof isScrollLocked;
  clearAllScrollLocks: typeof clearAllScrollLocks;
  lockStateMap: WeakMap<HTMLElement | SVGElement, LockState>;
  lockedElementSet: Set<HTMLElement | SVGElement>;
};

Returns:

  • The same functions and constants the package exports, bound to the new instance. The top-level exports are one shared instance created this way.

Types

ScrollLockTarget

type ScrollLockTarget =
  HTMLElement | SVGElement | Window | Document | null | undefined;

Target Resolution:

  • HTMLElement | SVGElement - Used directly as the lock target
  • Window - Targets window.document.documentElement
  • Document - Targets document.documentElement
  • null | undefined - Defaults to document.body

LockScrollOptions

interface LockScrollOptions {
  reserveScrollBarGap?: boolean;
}

LockState

interface LockState {
  count: number;
  originalOverflowX?: string;
  originalOverflowY?: string;
  reservedScrollBarGap?: ReservedScrollBarGap;
  stopTouchEventListener?: () => void;
  stopWatchingStyle?: () => void;
}

interface ReservedScrollBarGap {
  property: "scrollbar-gutter" | "padding-left" | "padding-right";
  original: string;
  value: string;
  width: number;
}

Exported Constants

Advanced usage - access the internal state of the shared instance:

import { lockStateMap, lockedElementSet } from "@hunterliu/scroll-lock";

// WeakMap storing lock state for each element
const state = lockStateMap.get(document.body);

// Set of all currently locked elements
const isLocked = lockedElementSet.has(document.body);