Agents (llms.txt)
Octocat

useScrollLock

A hook that allows you to disable scrolling on an HTML element.

Source Code

import { type RefObject, useEffect, useRef } from "react";

export type ScrollLockTarget = string | HTMLElement | RefObject<HTMLElement> | undefined;

const getTargetElement = (target: ScrollLockTarget): HTMLElement | null => {
  if (!target) return document.body;
  if (target instanceof HTMLElement) return target;
  if (typeof target === "string") return document.querySelector(target);
  if (target && "current" in target) return target.current || null;

  return null;
};

interface StyleBackup {
  overflow: string;
  scrollbarGutter: string;
}

const styleBackupMap = new WeakMap<HTMLElement, StyleBackup>();
const lockCounterMap = new WeakMap<HTMLElement, number>();

export const useScrollLock = (isLocked: boolean, target?: ScrollLockTarget) => {
  const targetElementRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    targetElementRef.current = getTargetElement(target);
  }, [target]);

  useEffect(() => {
    const targetElement = targetElementRef.current;
    if (!targetElement || !isLocked) return;

    const currentCount = lockCounterMap.get(targetElement) || 0;

    // Backup styles before first lock
    if (currentCount === 0) {
      styleBackupMap.set(targetElement, {
        overflow: targetElement.style.overflow,
        scrollbarGutter: targetElement.style.scrollbarGutter,
      });
    }

    const newCount = currentCount + 1;
    lockCounterMap.set(targetElement, newCount);

    Object.assign(targetElement.style, {
      overflow: "hidden",
      scrollbarGutter: "stable",
    });

    return () => {
      const currentCount = lockCounterMap.get(targetElement) || 0;
      const newCount = Math.max(0, currentCount - 1);
      lockCounterMap.set(targetElement, newCount);

      if (newCount === 0) {
        const previousStyles = styleBackupMap.get(targetElement) || {
          overflow: "",
          scrollbarGutter: "",
        };

        Object.assign(targetElement.style, previousStyles);
        styleBackupMap.delete(targetElement);
      }
    };
  }, [isLocked]);
};

Features

  • No Layout Shift - Forces scrollbar-gutter to stable to prevent layout shifts when the scroll is locked.
  • No Side Effects - The hook only adds styles to the target element and removes them (reverting to the previous styles) when the hook is unmounted.
  • Custom Target - Allows you to lock the scroll on a specific element or the entire page.

API Reference

PropDefaultTypeDescription
isLocked *-booleanWhether to lock the scroll.
target-string | HTMLElement | RefObject<HTMLElement>The target element to lock the scroll on. Can be a string (selector), HTMLElement, or a RefObject with a HTMLElement.

Examples

Basic

import { useState } from "react";

import { useScrollLock } from "@/foundations/hooks/use-scroll-lock";
import { Button } from "@/components/button";

const UseScrollLockPreview = () => {
  const [isLocked, setIsLocked] = useState(false);

  useScrollLock(isLocked);

  return (
    <Button onClick={() => setIsLocked(!isLocked)} size="sm">
      {isLocked ? "Unlock" : "Lock"}
    </Button>
  );
};

export default UseScrollLockPreview;

With Custom Target

Scroll me
import { useState } from "react";

import { useScrollLock } from "@/foundations/hooks/use-scroll-lock";
import { Button } from "@/components/button";

const UseScrollLockTargetPreview = () => {
  const [isLocked, setIsLocked] = useState(false);

  useScrollLock(isLocked, "#scroll-lock-target");

  return (
    <>
      <main id="scroll-lock-target" className="absolute inset-0 overflow-y-auto">
        <div className="h-500" />
      </main>
      <div
        className="absolute inset-0 flex items-center justify-center text-foreground-secondary"
        inert
      >
        Scroll me
      </div>
      <Button onClick={() => setIsLocked(!isLocked)} className="absolute top-4 left-4" size="sm">
        {isLocked ? "Unlock" : "Lock"}
      </Button>
    </>
  );
};

export default UseScrollLockTargetPreview;

Previous

usePrefersReducedMotion

Next

useStableCallback