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().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
- 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, 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.)
- 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).
- 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
Component
Dialog
Package version
@fluentui/react-headless-components-preview0.2.5React version
18.3.1
Environment
Current Behavior
components/Dialog/utils/scroll.ts—lockDocumentScrollclipsbodyoverflow and reservesnothing 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-componentsDialog, on the same page,with the same content:
@fluentui/react-componentsThe 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
Dialog.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-scrollbarsby default. Under thatflag 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 withoverlay scrollbars (macOS default, some Linux desktops).
Steps to reproduce
ignoreDefaultArgs: ['--hide-scrollbars']if driving viapuppeteer).
Dialog; read the marker's x.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-componentstwin at threshold 0, and the Dialog scenes would not settle: the twosides 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:
documentElement, notbody.overflowpropagates from body to theviewport — which is why the existing lock works with
<html>leftvisible— butscrollbar-gutterdoes not. Measured:scrollbar-gutter: stableonbodyalongside the clipreserves nothing and leaves the full 7.5px jump. Padding
bodyinstead was also measured, andalso fails, because it narrows
bodyrather than the initial containing block that a top-layersurface resolves against. (The existing docblock promises to leave
<html>untouched; thatpromise cannot be kept, and the fix should rewrite it to say why.)
lock removes it.
scrollbar-gutter: stablereserves unconditionally, so writing it alwaysintroduces 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).
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-31returns early unlessfloor(body.height) > window.innerHeight) where the headless lock always runs. ThescrollbarWidth > 0guard above addresses the directionality; the unconditional-versus-gateddifference 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 tosplit 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