{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "scroll-lock", "title": "Scroll Lock", "description": "Prevent body/element scrolling with reference counting", "dependencies": [], "registryDependencies": [], "files": [ { "path": "src/hooks/use-scroll-lock.ts", "content": "\"use client\";\n\nimport { type RefObject, useEffect, useRef } from \"react\";\n\nconst lockCounts = new WeakMap();\nconst savedOverflow = new WeakMap();\nconst savedPaddingRight = new WeakMap();\n\n/** Options for preventing body or element scrolling. */\nexport interface UseScrollLockOptions {\n /** Ref to the element to lock. Defaults to `document.body` when omitted. */\n target?: RefObject;\n /** Whether the scroll lock is active. */\n enabled?: boolean;\n}\n\nfunction measureScrollbarWidth(el: HTMLElement): number {\n const ownerDocument = el.ownerDocument;\n const isViewport = el === ownerDocument.body || el === ownerDocument.scrollingElement;\n if (isViewport) {\n const view = ownerDocument.defaultView;\n if (!view) return 0;\n return view.innerWidth - ownerDocument.documentElement.clientWidth;\n }\n const style = ownerDocument.defaultView?.getComputedStyle(el);\n const borderLeft = style ? Number.parseFloat(style.borderLeftWidth) || 0 : 0;\n const borderRight = style ? Number.parseFloat(style.borderRightWidth) || 0 : 0;\n return Math.max(0, el.offsetWidth - el.clientWidth - borderLeft - borderRight);\n}\n\nfunction lockElement(el: HTMLElement): () => void {\n const count = lockCounts.get(el) ?? 0;\n\n if (count === 0) {\n savedOverflow.set(el, el.style.overflow);\n savedPaddingRight.set(el, el.style.paddingRight);\n\n const scrollbarWidth = measureScrollbarWidth(el);\n if (scrollbarWidth > 0) {\n const view = el.ownerDocument.defaultView;\n const currentPaddingRight = view\n ? Number.parseFloat(view.getComputedStyle(el).paddingRight) || 0\n : 0;\n el.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;\n }\n\n el.style.overflow = \"hidden\";\n el.setAttribute(\"data-scroll-locked\", \"\");\n }\n lockCounts.set(el, count + 1);\n\n return () => {\n const current = lockCounts.get(el) ?? 1;\n const next = current - 1;\n if (next <= 0) {\n lockCounts.delete(el);\n el.style.overflow = savedOverflow.get(el) ?? \"\";\n el.style.paddingRight = savedPaddingRight.get(el) ?? \"\";\n el.removeAttribute(\"data-scroll-locked\");\n savedOverflow.delete(el);\n savedPaddingRight.delete(el);\n } else {\n lockCounts.set(el, next);\n }\n };\n}\n\n// Effect-on-every-render is intentional: React does not re-fire effects when\n// target.current mutates while the ref object stays stable.\n/**\n * Locks scrolling by mutating the target element's overflow and padding, with\n * scrollbar-width compensation and shared reference counting. It installs no\n * event listeners.\n */\nexport function useScrollLock(options: UseScrollLockOptions = {}): void {\n const { target, enabled = true } = options;\n const lockedElementRef = useRef(null);\n const releaseRef = useRef<(() => void) | null>(null);\n\n // No dependency array on purpose; see hook-level comment above.\n useEffect(() => {\n let nextElement: HTMLElement | null = null;\n if (enabled) {\n nextElement = target ? target.current : document.body;\n }\n if (lockedElementRef.current === nextElement) return;\n\n releaseRef.current?.();\n releaseRef.current = null;\n lockedElementRef.current = null;\n\n if (!nextElement) return;\n\n releaseRef.current = lockElement(nextElement);\n lockedElementRef.current = nextElement;\n });\n\n useEffect(() => {\n return () => {\n releaseRef.current?.();\n releaseRef.current = null;\n lockedElementRef.current = null;\n };\n }, []);\n}\n", "type": "registry:hook", "target": "src/hooks/use-scroll-lock.ts" } ], "meta": { "client": true }, "type": "registry:hook" }