useScrollLock

Lock scrolling of the element while preserving scrolling within nested scrollable containers.

useScrollLock prevents scrolling on a target element by setting overflow: hidden on the element’s style. It returns a tuple of the current lock state (boolean) and a setter function to toggle the lock. Crucially, it preserves scrolling within nested scrollable containers inside the locked element, so inner content remains interactive. The lock is automatically released when the component unmounts.

When to Use

  • Preventing background scroll when a modal, dialog, or drawer is open
  • Locking scroll on the body while displaying a fullscreen overlay or lightbox
  • Disabling scroll temporarily during drag-and-drop operations to prevent accidental scrolling

Notes

  • SSR-safe: Returns false for the lock state and a no-op setter during server-side rendering. No DOM style manipulation occurs on the server.
  • Nested scrolling: Unlike naive overflow: hidden approaches, this hook preserves scrolling within child scrollable containers, making it suitable for dialogs with scrollable content.
  • Related hooks: See also useScroll for tracking scroll position, useScrollIntoView for programmatic scrolling, and useFullscreen for immersive display modes.

Usage

Live Editor
function Demo() {
  const elementRef = useRef<HTMLDivElement>(null);
  const [locked, setLocked] = useScrollLock(elementRef);

  const absoluteStyle: CSSProperties = {
    paddingTop: "0.25rem",
    paddingBottom: "0.25rem",
    paddingLeft: "0.5rem",
    paddingRight: "0.5rem",
    position: "absolute",
  };
  return (
    <div style={{ display: "flex" }}>
      <div
        ref={elementRef}
        style={{
          width: 300,
          height: 300,
          margin: "auto",
          borderRadius: "0.25rem",
          overflow: "scroll",
          border: "1px solid var(--ifm-color-emphasis-200)",
        }}
      >
        <div style={{ width: 500, height: 400, position: "relative" }}>
          <div
            style={{
              ...absoluteStyle,
              top: "0rem",
              left: "0rem",
            }}
          >
            TopLeft
          </div>
          <div
            style={{
              ...absoluteStyle,
              bottom: "0rem",
              left: "0rem",
            }}
          >
            BottomLeft
          </div>
          <div
            style={{
              ...absoluteStyle,
              top: "0rem",
              right: "0rem",
            }}
          >
            TopRight
          </div>
          <div
            style={{
              ...absoluteStyle,
              bottom: "0rem",
              right: "0rem",
            }}
          >
            BottomRight
          </div>
          <div
            style={{
              ...absoluteStyle,
              top: "33.33333%",
              left: "33.33333%",
            }}
          >
            Scroll Me
          </div>
        </div>
      </div>
      <div
        style={{
          width: 280,
          margin: "auto",
          paddingLeft: "1rem",
          display: "flex",
          flexDirection: "column",
          gap: 5,
        }}
      >
        <div>Locked: {JSON.stringify(locked)}</div>
        <button
          onClick={() => {
            setLocked(!locked);
          }}
          style={{
            padding: "8px 16px",
            border: "1px solid var(--ifm-color-emphasis-300)",
            backgroundColor: "var(--ifm-background-color)",
            color: "var(--ifm-color-content)",
            borderRadius: "4px",
            cursor: "pointer",
          }}
        >
          {locked ? "Unlock" : "Lock"}
        </button>
      </div>
    </div>
  );
};
Result

Dialog Example

Live Editor
function DialogDemo() {
  const { cloneElement, ReactElement, useRef, useState } = React;
  
  const Dialog = ({ trigger }: { trigger: ReactElement }) => {
    const dialogRef = useRef<HTMLDialogElement>(null);
    const boxRef = useRef(null);
    const [isOpen, setIsOpen] = useState(false);
    const [, setLock] = useScrollLock(document.body);

    const open = () => {
      setIsOpen(true);
      dialogRef.current?.showModal();
      setLock(true);
    };

    const close = () => {
      setIsOpen(false);
      dialogRef.current?.close();
      setLock(false);
    };

    useEventListener("close", () => setIsOpen(false), dialogRef);
    useClickOutside(boxRef, close);

    return (
      <>
        {cloneElement(trigger, { onClick: open })}

        <dialog 
          ref={dialogRef}
          style={{
            padding: 0,
            border: "none",
            borderRadius: "8px",
            boxShadow: "0 10px 25px rgba(0, 0, 0, 0.1)",
            maxWidth: "500px",
            width: "90vw",
          }}
        >
          <div 
            ref={boxRef}
            style={{
              padding: "24px",
              maxHeight: "70vh",
              overflow: "auto",
              backgroundColor: "var(--ifm-background-color)",
              borderRadius: "8px",
            }}
          >
            <h3 style={{ margin: "0 0 16px 0", color: "var(--ifm-color-content)" }}>
              Dialog with Scrollable Content
            </h3>
            <p style={{ lineHeight: "1.6", color: "var(--ifm-color-content-secondary)", margin: "16px 0" }}>
              This dialog demonstrates how useScrollLock works. 
              The background scrolling is locked, but you can still scroll within this dialog content.
            </p>
            <p style={{ lineHeight: "1.6", color: "var(--ifm-color-content-secondary)", margin: "16px 0" }}>
              Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque
              faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi
              pretium tellus duis convallis. Tempus leo eu aenean sed diam urna
              tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas.
              Iaculis massa nisl malesuada lacinia integer nunc posuere.
            </p>
            <p style={{ lineHeight: "1.6", color: "var(--ifm-color-content-secondary)", margin: "16px 0" }}>
              Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora
              torquent per conubia nostra inceptos himenaeos. Lorem ipsum dolor 
              sit amet consectetur adipiscing elit. Quisque faucibus ex sapien 
              vitae pellentesque sem placerat.
            </p>
            <p style={{ lineHeight: "1.6", color: "var(--ifm-color-content-secondary)", margin: "16px 0" }}>
              In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean 
              sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus 
              bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere.
            </p>
            <p style={{ lineHeight: "1.6", color: "var(--ifm-color-content-secondary)", margin: "16px 0" }}>
              Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora
              torquent per conubia nostra inceptos himenaeos. Sed cursus turpis 
              a purus aliquam condimentum. Nam bibendum cursus dolor.
            </p>
            <div style={{ 
              display: "flex", 
              justifyContent: "flex-end", 
              marginTop: "24px",
              gap: "12px"
            }}>
              <button
                onClick={close}
                style={{
                  padding: "8px 16px",
                  border: "1px solid var(--ifm-color-emphasis-300)",
                  backgroundColor: "var(--ifm-background-color)",
                  color: "var(--ifm-color-content)",
                  borderRadius: "4px",
                  cursor: "pointer",
                }}
              >
                Close
              </button>
            </div>
          </div>
        </dialog>
      </>
    );
  };

  return (
    <div style={{ padding: "20px", textAlign: "center" }}>
      <Dialog 
        trigger={
          <button
            style={{
              padding: "12px 24px",
              border: "1px solid var(--ifm-color-primary)",
              backgroundColor: "var(--ifm-color-primary)",
              color: "var(--ifm-color-white)",
              borderRadius: "4px",
              cursor: "pointer",
              fontSize: "16px",
            }}
          >
            Open Dialog
          </button>
        } 
      />
      <p style={{ 
        marginTop: "16px", 
        color: "var(--ifm-color-content-secondary)", 
        fontSize: "14px",
        maxWidth: "400px",
        margin: "16px auto 0"
      }}>
        Open the dialog and try scrolling. You'll be able to scroll 
        within the dialog content while background scrolling remains locked.
      </p>
    </div>
  );
}
Result

API

useScrollLock

Returns

readonly [boolean, (flag: boolean) => void]: A tuple with the following elements:

  • whether scroll is locked.
  • A function to update the value of lock state.

Arguments

ArgumentDescriptionTypeDefaultValue
targetdom elementBasicTarget<HTMLElement> (Required)-
initialStatedefault valueboolean | undefinedfalse

BasicTarget

export type BasicTarget<T extends TargetType = Element> = (() => TargetValue<T>) | TargetValue<T> | MutableRefObject<TargetValue<T>>;

TargetValue

type TargetValue<T> = T | undefined | null;

TargetType

type TargetType = HTMLElement | Element | Window | Document | EventTarget;