@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
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-lockBasic 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)); // falseReference 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 unlockedCustom 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 unlockedScrollbar 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 restoredThe 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 unlockedIsolated 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 lockedInstances 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 todocument.bodyifnull/undefined)options?: LockScrollOptions- Optional configuration objectreserveScrollBarGap?: boolean- Keep the room of the disappearing scrollbar, see Scrollbar Gap (default:false)
Returns:
LockState | undefined- The lock state object, orundefinedif 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 todocument.bodyifnull/undefined)options?- Optional configuration objectforce?: boolean- Bypass reference counting (default:false)
Returns:
LockState | undefined- The lock state object, orundefinedif not in browser environment
isScrollLocked(target)
Check if target is currently locked.
function isScrollLocked(target: ScrollLockTarget): boolean;Parameters:
target: ScrollLockTarget- Element to check (defaults todocument.bodyifnull/undefined)
Returns:
boolean-trueif the element is locked,falseotherwise
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 targetWindow- Targetswindow.document.documentElementDocument- Targetsdocument.documentElementnull | undefined- Defaults todocument.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);