# Locking Body Scroll Must Not Shift the Page

**SHOULD** · **ID:** `layout-dialog-scroll-lock` · **Category:** layout
**Source:** [@Ibelick](https://www.ui-skills.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/layout-dialog-scroll-lock

> SHOULD: Opening a dialog must not shift the page behind it. `document.body.style.overflow = "hidden"` removes the scrollbar track, so the viewport grows ~15px and everything behind the scrim jumps sideways (and back on close). Reserve the track with `scrollbar-gutter: stable` (or pad by `window.innerWidth - document.documentElement.clientWidth` on legacy targets), and put `overscroll-behavior: contain` on the dialog's own scroll area so a flick inside it does not chain out.

The usual overflow:hidden scroll lock removes the scrollbar, which reflows the whole page ~15px wider behind the scrim

Locking background scroll while a dialog is open is correct. The one-line way everyone does it — `document.body.style.overflow = "hidden"` — is what causes the lurch. On any platform with classic overlay-less scrollbars, hiding overflow removes the scrollbar track, the viewport gets roughly 15px wider, and every centered or full-width element behind the scrim jumps sideways at the exact moment the user's attention moves to the dialog. Then it jumps back on close. It reads as a bug even to people who cannot name it. Two fixes, use both: `scrollbar-gutter: stable` on the scroll container reserves the track permanently so removing the scrollbar changes nothing (or, on legacy targets, pad by `window.innerWidth - documentElement.clientWidth`), and `overscroll-behavior: contain` on the dialog's own scroll area stops a flick inside it from chaining out to the page. That second half is interactions-overscroll-behavior and it is a genuinely different failure — chaining is about where a scroll *goes*, this is about the layout *shifting* when scrolling is taken away.

## Rule snippet

```tsx
html { scrollbar-gutter: stable; }
.dialog-body { overscroll-behavior: contain; }
```

## Bad — do not do this

`layout-dialog-scroll-lock-bad`

```tsx
import { useState } from 'react';

/**
 * BAD: the one-liner scroll lock — `overflow: hidden` on the scroll container.
 * Hiding overflow removes the scrollbar track, the content box gets ~12px wider, and
 * everything behind the scrim lurches sideways at the exact moment the dialog appears.
 * Then it lurches back on close.
 */
export function DialogScrollLockBad() {
  const [open, setOpen] = useState(false);

  const button =
    'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring';

  return (
    <div className="space-y-3">
      <style>{`
        /* A classic (space-taking) scrollbar, so the shift is visible on every platform. */
        .dsl-page-bad::-webkit-scrollbar { width: 12px; }
        .dsl-page-bad::-webkit-scrollbar-thumb { border-radius: 6px; background-color: currentColor; }
      `}</style>

      <div
        className="dsl-page-bad relative h-56 overflow-y-auto rounded-lg border border-border bg-muted text-muted-foreground"
        style={{ overflowY: open ? 'hidden' : 'auto' }}
      >
        <div className="space-y-3 p-4">
          <div className="mx-auto w-64 rounded-md border border-border bg-card p-3 text-center text-sm text-foreground">
            Centered card — watch me jump
          </div>
          <p className="text-xs">Scroll me. Then open the dialog and watch the card shift right.</p>
          <div className="h-72 rounded-md border border-border" />
          <button type="button" className={button} onClick={() => setOpen(true)}>
            Open dialog
          </button>
        </div>

        {open && (
          <div className="sticky bottom-4 mx-4 rounded-lg border border-border bg-card p-4">
            <p className="text-sm text-foreground">The page behind me just moved 12px.</p>
            <button type="button" className={`${button} mt-2`} onClick={() => setOpen(false)}>
              Close
            </button>
          </div>
        )}
      </div>

      <p className="text-xs text-destructive">
        Nothing scrolled, yet everything moved. The layout got wider because the scrollbar left, and the
        user reads that lurch as a bug even if they cannot name it.
      </p>
    </div>
  );
}
```

## Good — do this

`layout-dialog-scroll-lock-good`

```tsx
import { useState } from 'react';

/**
 * GOOD: `scrollbar-gutter: stable` reserves the scrollbar track permanently, so taking
 * the scrollbar away changes no geometry at all. The dialog's own scroll area adds
 * `overscroll-behavior: contain` so a flick inside it never chains out to the page.
 */
export function DialogScrollLockGood() {
  const [open, setOpen] = useState(false);

  const button =
    'rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring';

  return (
    <div className="space-y-3">
      <style>{`
        .dsl-page-good::-webkit-scrollbar { width: 12px; }
        .dsl-page-good::-webkit-scrollbar-thumb { border-radius: 6px; background-color: currentColor; }
        /* The gutter is reserved whether or not a scrollbar is currently drawn. */
        .dsl-page-good { scrollbar-gutter: stable; }
        .dsl-dialog-scroll { overscroll-behavior: contain; }
      `}</style>

      <div
        className="dsl-page-good relative h-56 overflow-y-auto rounded-lg border border-border bg-muted text-muted-foreground"
        style={{ overflowY: open ? 'hidden' : 'auto' }}
      >
        <div className="space-y-3 p-4">
          <div className="mx-auto w-64 rounded-md border border-border bg-card p-3 text-center text-sm text-foreground">
            Centered card — perfectly still
          </div>
          <p className="text-xs">Scroll me. Then open the dialog: nothing behind it moves.</p>
          <div className="h-72 rounded-md border border-border" />
          <button type="button" className={button} onClick={() => setOpen(true)}>
            Open dialog
          </button>
        </div>

        {open && (
          <div className="sticky bottom-4 mx-4 rounded-lg border border-border bg-card p-4">
            <div className="dsl-dialog-scroll max-h-20 overflow-y-auto text-sm text-foreground">
              <p>Scroll inside me and the page underneath stays put — overscroll-behavior: contain.</p>
              <p className="mt-2 text-xs text-muted-foreground">More dialog content…</p>
              <p className="mt-2 text-xs text-muted-foreground">…and a bit more.</p>
            </div>
            <button type="button" className={`${button} mt-2`} onClick={() => setOpen(false)}>
              Close
            </button>
          </div>
        )}
      </div>

      <p className="text-xs text-success">
        Scrolling is locked and the layout is byte-for-byte identical: the gutter never left. On legacy
        targets, pad by `window.innerWidth - document.documentElement.clientWidth` for the same result.
      </p>
    </div>
  );
}
```

## References

- [ibelick — fixing-accessibility SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-accessibility/SKILL.md)
- [MDN — scrollbar-gutter](https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-gutter)
- [MDN — overscroll-behavior](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior)
