Appearance
Scroll Lock
When to use
Use scroll lock when a modal dialog, drawer, or fullscreen overlay opens and you need to prevent the page from scrolling behind it. On desktop browsers, overflow: hidden on the body is often enough. On iOS Safari, however, the page still scrolls unless you also fix the body in place with position: fixed and compensate for the current scroll offset. This pattern handles both cases and correctly supports nested modals that each request a scroll lock.
The pattern
Using scrollLock()
scrollLock() returns an unlock function. Call it when a modal opens to fix the page in place, and call the returned function when the modal closes to restore scroll position. Nested modals work automatically — the page stays fixed until the last lock is released:
javascript
let unlock;
function openModal() {
unlock = scrollLock();
modal.style.display = "block";
}
function closeModal() {
modal.style.display = "none";
unlock();
}Multiple overlays can safely stack. Each scrollLock() call increments an internal counter, and calling the unlock function decrements it. The scroll position is only restored when the counter reaches zero:
javascript
// Settings panel opens — body fixed, lockCount = 1
const unlockSettings = scrollLock();
// Confirmation dialog opens on top — lockCount = 2
const unlockConfirm = scrollLock();
// Confirmation closes — lockCount = 1, body still fixed
unlockConfirm();
// Settings closes — lockCount = 0, body unfixed,
// scroll position restored
unlockSettings();Implementing scrollLock()
scrollLock() fixes the scrolling element in place at its current scroll offset using position: fixed with a negative top value, then stores the original scroll position and a reference count in a WeakMap keyed to the element. On iOS Safari, overflow: hidden alone does not prevent scrolling — the fixed position technique removes the element from the normal document flow entirely. The WeakMap ensures lock state is garbage-collected if the element is removed from the DOM:
javascript
const lockedElements = new WeakMap();
function scrollLock(
element = document.scrollingElement
) {
if (lockedElements.has(element)) {
lockedElements.get(element).lockCount++;
return () => scrollUnlock(element);
}
const { scrollTop, scrollLeft } = element;
Object.assign(element.style, {
overflow: "hidden",
position: "fixed",
top: `-${scrollTop}px`,
left: `-${scrollLeft}px`,
width: "100%",
});
lockedElements.set(element, {
scrollTop,
scrollLeft,
lockCount: 1,
});
return () => scrollUnlock(element);
}
function scrollUnlock(
element = document.scrollingElement
) {
if (!lockedElements.has(element)) return;
const data = lockedElements.get(element);
data.lockCount--;
if (data.lockCount === 0) {
const { scrollTop, scrollLeft } = data;
Object.assign(element.style, {
overflow: "",
position: "",
top: "",
left: "",
width: "",
});
element.scrollTop = scrollTop;
element.scrollLeft = scrollLeft;
lockedElements.delete(element);
}
}Using ScrollLock class
The ScrollLock class wraps the functions in a cleaner interface for component-based frameworks. Instead of managing an unlock function, you call lock() and unlock() on an instance:
javascript
const lock = new ScrollLock();
function openModal() {
lock.lock();
modal.style.display = "block";
}
function closeModal() {
modal.style.display = "none";
lock.unlock();
}Implementing ScrollLock
The class takes an element or CSS selector and delegates to the scrollLock() function. Calling lock() releases any existing lock first, then acquires a new one. The locked getter reports whether a lock is currently held:
javascript
class ScrollLock {
#unlock = undefined;
#element = null;
constructor(
elementOrSelector = document.scrollingElement
) {
this.#element =
elementOrSelector instanceof HTMLElement
? elementOrSelector
: document.querySelector(elementOrSelector);
if (!this.#element) {
throw new Error(
"elementOrSelector must be an element "
+ "or a valid CSS selector."
);
}
}
get locked() {
return Boolean(this.#unlock);
}
lock() {
this.unlock();
this.#unlock = scrollLock(this.#element);
}
unlock() {
if (typeof this.#unlock === "function") {
this.#unlock();
}
this.#unlock = undefined;
}
}For a Vue composable wrapper, see useScrollLock in the vue skill.
Combined with inert controller
A complete modal open/close sequence typically pairs scroll locking with inert control:
javascript
const inert = new InertController();
const lock = new ScrollLock();
async function open() {
dialog.show();
lock.lock();
inert.on();
}
async function close() {
dialog.close();
lock.unlock();
inert.off();
}Trade-offs
- Fixed positioning side effects. Applying
position: fixedto the scrolling element can affect child elements that useposition: stickyorposition: fixedrelative to the viewport. Test thoroughly with your layout. - Scroll position precision.
scrollTopreturns an integer in some browsers and a float in others. The restored position may be off by a sub-pixel amount, which is typically imperceptible. - Width: 100%. Setting
width: 100%prevents the body from collapsing to zero width when fixed. If your layout depends on the scrollbar width (e.g. content reflows when the scrollbar disappears), you may need to addpadding-rightequal to the scrollbar width while locked. - Emotion / CSS-in-JS dependency. The source material uses Emotion's
csstagged template to generate a class name. The examples above use inline styles instead, removing the library dependency. If you already use Emotion or a similar library, the class-based approach is cleaner because removing a class is more reliable than resetting individual style properties. - No passive event listeners. This pattern does not intercept
touchmoveevents. Older scroll-locking approaches usedpreventDefault()on touch events, but fixed positioning makes that unnecessary and avoids conflicts with passive event listener requirements in modern browsers.