Skip to content

[Bug]: headless Dialog's scroll lock does not reserve the scrollbar gutter, so opening a modal shifts the page #36648

Description

Component

Dialog

Package version

@fluentui/react-headless-components-preview 0.2.5

React version

18.3.1

Environment

node v22.12.0
@fluentui/react-headless-components-preview 0.2.5
Chrome 147, classic (layout-taking) scrollbars

Current Behavior

components/Dialog/utils/scroll.tslockDocumentScroll clips body overflow and reserves
nothing in the scrollbar's place. On a page that scrolls, opening a modal dialog therefore removes
the scrollbar's layout width, and every fixed and centred element on the page shifts sideways
for as long as the dialog is open.

Measured end to end against the equivalent @fluentui/react-components Dialog, on the same page,
with the same content:

before open after open Δx
headless (today) 332.5 340 7.5
@fluentui/react-components 332.5 332.5 0

The jump is the scrollbar's width, and it is visible as a lurch on every fixed header, toast
region, and centred surface at the moment the dialog opens.

Expected Behavior

Locking scroll should not change the layout width available to the page. The Griffel-styled Dialog
does not shift, and the headless one should match.

Reproduction

  1. A page tall enough to scroll, with a fixed or centred element whose x position you can read.
  2. Open a modal headless Dialog.
  3. Read the element's getBoundingClientRect().x before and after.

�� Reproduction trap — please read before dismissing this as stale. Puppeteer (and anything
built on it, including this repo's VR runner) passes --hide-scrollbars by default. Under that
flag the scrollbar takes no layout width, so this defect cannot appear at all — and it even
inverts, making the Griffel side look like the one that shifts. Every measurement above was taken
with ignoreDefaultArgs: ['--hide-scrollbars']. The same caveat applies to any environment with
overlay scrollbars (macOS default, some Linux desktops).

Steps to reproduce

  1. Launch Chrome with classic scrollbars (ignoreDefaultArgs: ['--hide-scrollbars'] if driving via
    puppeteer).
  2. Render a scrolling page with a centred marker element.
  3. Open a modal headless Dialog; read the marker's x.
  4. Observe a shift equal to the scrollbar width.

Discovery context

Found during pixel-level verification of a styling layer built over
@fluentui/react-headless-components-preview. The layer's parity gate diffs every scene against its
@fluentui/react-components twin at threshold 0, and the Dialog scenes would not settle: the two
sides disagreed by a whole-page translation whenever the underlying page scrolled.

Proposed fix

Reserve the gutter while the lock is held. Three points that were measured rather than assumed,
and that a fix needs to get right:

  1. The gutter must go on documentElement, not body. overflow propagates from body to the
    viewport — which is why the existing lock works with <html> left visible — but
    scrollbar-gutter does not. Measured: scrollbar-gutter: stable on body alongside the clip
    reserves nothing and leaves the full 7.5px jump. Padding body instead was also measured, and
    also fails, because it narrows body rather than the initial containing block that a top-layer
    surface resolves against. (The existing docblock promises to leave <html> untouched; that
    promise cannot be kept, and the fix should rewrite it to say why.)
  2. The write must be guarded on the scrollbar actually taking layout width, read before the
    lock removes it. scrollbar-gutter: stable reserves unconditionally, so writing it always
    introduces the mirror-image defect — measured at Δx −7.5 on a page that never scrolled. The
    same guard reads 0 under overlay scrollbars, which is the case where Griffel's own height-based
    guard over-reserves (measured: Griffel Δx −7.5 with overlay scrollbars, headless 0 after the fix).
  3. Unlock must restore the previous inline gutter alongside the previous overflow.

Non-modal dialogs never take the lock and were measured unaffected.

Four regression tests were added; the two behavioural ones were verified to fail without the
fix
. The headless suite was green at 989/989 with the change.

Related divergence, recorded but deliberately not addressed here: Griffel's lock is gated on the
page actually overflowing (useDisableBodyScroll.ts:25-31 returns early unless
floor(body.height) > window.innerHeight) where the headless lock always runs. The
scrollbarWidth > 0 guard above addresses the directionality; the unconditional-versus-gated
difference is a separate open question and is not part of this fix.

A working implementation is included in PR #[WINDMOD-PR-NUMBER] (commit 17f7e796d8); happy to
split it into a standalone PR against this issue.

Suggested severity

High - No workaround (a consumer cannot patch the package's internal scroll lock).

Are you willing to submit a PR to fix?

yes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions