Skip to content

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: fixed to the scrolling element can affect child elements that use position: sticky or position: fixed relative to the viewport. Test thoroughly with your layout.
  • Scroll position precision. scrollTop returns 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 add padding-right equal to the scrollbar width while locked.
  • Emotion / CSS-in-JS dependency. The source material uses Emotion's css tagged 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 touchmove events. Older scroll-locking approaches used preventDefault() on touch events, but fixed positioning makes that unnecessary and avoids conflicts with passive event listener requirements in modern browsers.