# useScrollLock

A hook that locks body scroll without layout shift.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/use-scroll-lock.json
```

[Registry JSON](https://mwui.vercel.app/r/use-scroll-lock.json)

## Preview

```tsx
import * as React from "react";

import { Button } from "@/components/ui/button";

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

export function Preview() {
  const [locked, setLocked] = React.useState(false);

  useScrollLock(locked);

  return (
    <div className="flex w-full max-w-sm flex-col gap-3 text-sm">
      <Button variant={locked ? "default" : "outline"} onClick={() => setLocked((value) => !value)}>
        {locked ? "Unlock page scroll" : "Lock page scroll"}
      </Button>

      <p className="text-xs text-muted-foreground">
        {locked
          ? "The page behind this preview will not scroll, and the layout did not shift."
          : "Lock it, then try scrolling the docs page."}
      </p>
    </div>
  );
}
```


## Source

### hooks/use-scroll-lock.ts

```ts
"use client";

import * as React from "react";

type UseScrollLockOptions = {
  /** Element to lock. Defaults to `document.body`. */
  target?: HTMLElement | null;
  /** CSS variable set to the measured scrollbar width while locked. */
  widthVariable?: string;
};

type LockRecord = {
  count: number;
  overflow: string;
  paddingRight: string;
};

const locks = new WeakMap<HTMLElement, LockRecord>();

function useScrollLock(locked = true, options: UseScrollLockOptions = {}): void {
  const { target, widthVariable = "--scrollbar-width" } = options;

  React.useEffect(() => {
    if (!locked) return undefined;

    const element = target ?? document.body;
    const existing = locks.get(element);

    if (existing) {
      existing.count += 1;
    } else {
      const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
      const record: LockRecord = {
        count: 1,
        overflow: element.style.overflow,
        paddingRight: element.style.paddingRight,
      };

      locks.set(element, record);

      const currentPadding = Number.parseFloat(getComputedStyle(element).paddingRight) || 0;

      element.style.overflow = "hidden";
      element.style.setProperty(widthVariable, `${scrollbarWidth}px`);

      if (scrollbarWidth > 0) {
        element.style.paddingRight = `${currentPadding + scrollbarWidth}px`;
      }
    }

    return () => {
      const record = locks.get(element);
      if (!record) return;

      record.count -= 1;
      if (record.count > 0) return;

      element.style.overflow = record.overflow;
      element.style.paddingRight = record.paddingRight;
      element.style.removeProperty(widthVariable);
      locks.delete(element);
    };
  }, [locked, target, widthVariable]);
}

export { useScrollLock, type UseScrollLockOptions };
```



## Usage

When a modal, drawer, or full-screen menu opens, the page behind it should stop scrolling. Setting
`overflow: hidden` on `<body>` does that — and on desktop it also removes the scrollbar, which
shifts the whole layout a few pixels to the right. This hook compensates for the missing scrollbar
so nothing moves.

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

function Drawer({ open, children }: DrawerProps) {
  useScrollLock(open);

  return open ? <div className="fixed inset-0">{children}</div> : null;
}
```

Pass the open state directly — the hook is a no-op while it is `false`, so there is no conditional
hook call and no separate cleanup path.

## Nested locks

Locks are reference counted per element. A dialog that opens a confirmation on top of itself will
lock twice and unlock twice; scrolling is restored only when the last consumer releases it. Without
counting, closing the inner dialog would unlock the page while the outer one is still open.

## Fixed elements

While locked, the measured scrollbar width is published as a CSS variable on the locked element, so
position-fixed chrome can compensate too:

```css
.app-header {
  padding-right: var(--scrollbar-width, 0px);
}
```

## Options

| Option          | Description                                                       |
| --------------- | ----------------------------------------------------------------- |
| `target`        | Element to lock. Defaults to `document.body`.                      |
| `widthVariable` | Name of the CSS variable holding the scrollbar width. Defaults to `--scrollbar-width`. |

## Behavior

- The previous inline `overflow` and `padding-right` are captured before locking and written back on
  release, so an element that already had inline styles keeps them.
- Padding is added on top of the computed value rather than replacing it.
- Everything runs in an effect, so server rendering is unaffected.

