Important
@hunterliu/scroll-lock now lives in the vue-final-modal monorepo. Please open issues and pull requests there. This repository is no longer updated.
A lightweight, SSR-safe scroll locking library with reference counting support. Perfect for modals, dialogs, and overlays that need to prevent background scrolling.
- 🔒 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
# ✨ Auto-detect (supports npm, yarn, pnpm, deno and bun)
npx nypm install @hunterliu/scroll-lockimport {
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)); // falseThe 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 unlockedLock 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);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 unlockedWhen 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 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.
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 unlockedThe 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.
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
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
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
Clear all scroll locks on all targets.
function clearAllScrollLocks(): void;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.
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
interface LockScrollOptions {
reserveScrollBarGap?: boolean;
}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;
}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);