microsoft / microsoft/fluentui
[Bug]: headless Dialog's scroll lock does not reserve the scrollbar gutter, so opening a modal shifts the page
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 20.3k
- Forks
- 2.9k
- Avg merge
- 2d 9h
- Merged PRs (30d)
- 46
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.ts — lockDocumentScroll 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
- A page tall enough to scroll, with a fixed or centred element whose x position you can read.
- Open a modal headless
Dialog. - Read the element's
getBoundingClientRect().xbefore 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
- Launch Chrome with classic scrollbars (
ignoreDefaultArgs: ['--hide-scrollbars']if driving via
puppeteer). - Render a scrolling page with a centred marker element.
- Open a modal headless
Dialog; read the marker's x. - 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:
- The gutter must go on
documentElement, notbody.overflowpropagates from body to the
viewport — which is why the existing lock works with<html>leftvisible— but
scrollbar-gutterdoes not. Measured:scrollbar-gutter: stableonbodyalongside the clip
reserves nothing and leaves the full 7.5px jump. Paddingbodyinstead was also measured, and
also fails, because it narrowsbodyrather 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.) - The write must be guarded on the scrollbar actually taking layout width, read before the
lock removes it.scrollbar-gutter: stablereserves 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). - 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
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with components/Dialog/utils/scroll.ts and its existing lock/unlock behavior; compare the referenced useDisableBodyScroll.ts:25-31 gating. Review commit 17f7e796d8 and the four regression tests, then run the headless suite, reported green at 989/989. Done means modal dialogs preserve layout with classic scrollbars, overlay-scrollbar pages do not shift, and prior inline gutter state is restored on unlock.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100