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-guttertostableto 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
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