Join our Discord Community

useScrollLock

React hook that locks and unlocks scroll on target elements with width reflow prevention and automatic cleanup.

Loading component...

Installation

npx shadcn@latest add https://www.shadcn.io/registry/use-scroll-lock.json
npx shadcn@latest add https://www.shadcn.io/registry/use-scroll-lock.json
pnpm dlx shadcn@latest add https://www.shadcn.io/registry/use-scroll-lock.json
bunx shadcn@latest add https://www.shadcn.io/registry/use-scroll-lock.json

Features

  • Automatic locking - Locks scroll when component mounts by default
  • Manual control - Optional manual lock/unlock functions
  • Width reflow prevention - Prevents layout shift by compensating for scrollbar width
  • Custom targets - Lock scroll on specific elements or body
  • Style restoration - Restores original styles when unlocking
  • SSR compatible - Safely handles server-side rendering
  • Cleanup on unmount - Automatically unlocks scroll when component unmounts

Usage

import { useScrollLock } from "@/hooks/use-scroll-lock"

// Auto-lock on mount (perfect for modals)
function Modal() {
  useScrollLock()
  return <div>Modal content</div>
}

// Manual control
function App() {
  const { isLocked, lock, unlock } = useScrollLock({ autoLock: false })
  
  return (
    <div>
      <button onClick={lock}>Lock Scroll</button>
      <button onClick={unlock}>Unlock Scroll</button>
      <p>Status: {isLocked ? "Locked" : "Unlocked"}</p>
    </div>
  )
}

API Reference

useScrollLock

useScrollLock(options?: UseScrollLockOptions): UseScrollLockReturn

UseScrollLockOptions

PropertyTypeDefaultDescription
autoLockbooleantrueWhether to lock scroll automatically on mount
lockTargetHTMLElement | stringdocument.bodyTarget element to lock (element or CSS selector)
widthReflowbooleantrueWhether to prevent width reflow by adding padding

UseScrollLockReturn

PropertyTypeDescription
isLockedbooleanCurrent lock state
lock() => voidFunction to lock scroll
unlock() => voidFunction to unlock scroll

Usage Examples

Auto-Lock Modal

function Modal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
  // Automatically locks body scroll when modal is open
  useScrollLock()

  if (!isOpen) return null

  return (
    <div className="modal-overlay">
      <div className="modal">
        <h2>Modal Title</h2>
        <p>Body scroll is locked while this modal is open</p>
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  )
}

Manual Control

function ScrollableContent() {
  const { isLocked, lock, unlock } = useScrollLock({
    autoLock: false,
    lockTarget: "#scrollable-area"
  })

  return (
    <div>
      <div id="scrollable-area" style={{ height: 200, overflow: "auto" }}>
        {/* Long content */}
      </div>
      
      <button onClick={isLocked ? unlock : lock}>
        {isLocked ? "Unlock" : "Lock"} Scroll
      </button>
    </div>
  )
}

Custom Target Element

function CustomScrollLock() {
  const scrollRef = useRef<HTMLDivElement>(null)
  
  const { isLocked, lock, unlock } = useScrollLock({
    autoLock: false,
    lockTarget: scrollRef.current,
    widthReflow: false // Disable width reflow for custom elements
  })

  return (
    <div>
      <div ref={scrollRef} className="custom-scroll-area">
        Content
      </div>
      <button onClick={lock}>Lock Custom Area</button>
    </div>
  )
}

Sidebar/Drawer

function Sidebar({ isOpen }: { isOpen: boolean }) {
  useScrollLock({ autoLock: isOpen })

  return (
    <div className={`sidebar ${isOpen ? "open" : "closed"}`}>
      <p>Sidebar content</p>
      <p>Body scroll locked when sidebar is open</p>
    </div>
  )
}

Full-Screen Loading

function FullScreenLoader({ isLoading }: { isLoading: boolean }) {
  useScrollLock({ autoLock: isLoading })

  if (!isLoading) return null

  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center">
      <div className="spinner">Loading...</div>
    </div>
  )
}
function PhotoGallery() {
  const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null)
  
  // Lock scroll when photo is selected
  useScrollLock({ autoLock: !!selectedPhoto })

  return (
    <div>
      <div className="photo-grid">
        {photos.map(photo => (
          <img 
            key={photo.id}
            onClick={() => setSelectedPhoto(photo.src)}
            src={photo.thumbnail}
            alt={photo.alt}
          />
        ))}
      </div>
      
      {selectedPhoto && (
        <div className="photo-lightbox" onClick={() => setSelectedPhoto(null)}>
          <img src={selectedPhoto} alt="Full size" />
        </div>
      )}
    </div>
  )
}

Conditional Locking

function ConditionalLock() {
  const [shouldLock, setShouldLock] = useState(false)
  const isMobile = useMediaQuery("(max-width: 768px)")
  
  // Only lock scroll on mobile devices
  useScrollLock({ 
    autoLock: shouldLock && isMobile,
    widthReflow: !isMobile // Only prevent reflow on desktop
  })

  return (
    <div>
      <button onClick={() => setShouldLock(!shouldLock)}>
        Toggle Lock {isMobile ? "(Mobile)" : "(Desktop)"}
      </button>
    </div>
  )
}

Common Use Cases

  • Modal dialogs - Prevent background scrolling when modal is open
  • Sidebar/drawer navigation - Lock body scroll when side panel is active
  • Full-screen overlays - Loading screens, image lightboxes
  • Form wizards - Multi-step forms that shouldn't allow background interaction
  • Video players - Full-screen video modes
  • Game interfaces - Prevent accidental scrolling during gameplay
  • Mobile menus - Prevent body scroll when mobile menu is open
  • Infinite scroll pausing - Temporarily disable scroll during data loading

Implementation Details

  • Uses overflow: hidden to lock scroll
  • Calculates scrollbar width and adds equivalent padding to prevent layout shift
  • Stores original styles in refs for proper restoration
  • Uses useIsomorphicLayoutEffect to avoid SSR issues
  • Automatically unlocks scroll when component unmounts
  • Supports both CSS selectors and direct element references
  • Handles edge cases like missing target elements gracefully

Width Reflow Prevention

When widthReflow is enabled (default):

  • Calculates the scrollbar width by comparing offsetWidth and scrollWidth
  • Adds padding-right to compensate for the hidden scrollbar
  • Uses window.innerWidth for body element to account for global scrollbar
  • Preserves existing padding-right values by adding to them

Best Practices

  1. Use auto-lock for modals: Set autoLock: true for modal components
  2. Manual control for toggles: Use manual control for toggleable content
  3. Disable reflow for custom elements: Set widthReflow: false for non-body targets
  4. Handle mobile separately: Consider different behavior on mobile devices
  5. Test with existing padding: Ensure the hook works with elements that already have padding
  6. Clean up properly: The hook automatically cleans up, but be mindful of component lifecycles