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

Prop Default Type Description
isLocked * - boolean Whether 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

'use client';

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
'use client';

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-[2000px]" />
      </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