From 26361fbc70a76ae608ca182b9dd8921ce086ce68 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Tue, 25 Aug 2026 12:44:45 -0700 Subject: [PATCH 1/4] fix(react-headless-components-preview): reserve the scrollbar gutter while a dialog locks scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lockDocumentScroll sets body.style.overflow = 'visible clip' and nothing else, so the page scrollbar disappears without its space being reserved and every fixed or centred element on the page shifts sideways by half the scrollbar width when a modal opens. Measured on a scrolling 1280px page: a centred marker sits at x 332.5 with the dialog closed and jumps to 340 when it opens, where the Griffel equivalent stays at 332.5 because react-dialog's useDisableBodyScroll also reserves the gutter. The reservation goes on the document element, not on body. `overflow` propagates from body to the viewport — which is why the lock works at all with `` left `visible` — but `scrollbar-gutter` does not: measured, `scrollbar-gutter: stable` on body alongside the clip reserves nothing and leaves the same 7.5px jump. The docblock's promise to leave `` untouched cannot be kept and still fix this; padding body instead was measured too and does not help, because it narrows body rather than the initial containing block a top-layer surface resolves against. `stable` reserves a gutter whether or not the page had a scrollbar, so writing it unconditionally would introduce the mirror-image defect: a 7.5px shift on a page that never scrolled. It is written only when the scrollbar is actually taking layout width, read before the lock removes it. That reads 0 under overlay scrollbars as well, where nothing needs reserving and Griffel's own height-based guard over-reserves. Unlock restores the previous inline gutter next to the previous overflow, so a host application that had already set one gets it back, and the existing lockCount reference count scopes both to the outermost dialog. Non-modal dialogs never take the lock and are untouched. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../src/components/Dialog/Dialog.test.tsx | 86 +++++++++++++++++++ .../src/components/Dialog/utils/scroll.ts | 30 +++++-- 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx index bd0fcf8fe3c251..ee6232e8a777a9 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx @@ -106,6 +106,92 @@ describe('Dialog', () => { expect(dialog).not.toHaveAttribute('aria-labelledby'); }); + describe('scroll lock', () => { + // jsdom reports clientWidth 0, which would read as a scrollbar on every page. + const setScrollbarWidth = (width: number) => + Object.defineProperty(document.documentElement, 'clientWidth', { + configurable: true, + value: window.innerWidth - width, + }); + + afterEach(() => { + delete (document.documentElement as Partial).clientWidth; + document.documentElement.style.removeProperty('scrollbar-gutter'); + document.body.style.removeProperty('overflow'); + }); + + const renderModal = () => + render( + + + + + + Dialog title + + + + + + + , + ); + + it('reserves the scrollbar gutter while a modal holds the lock', () => { + setScrollbarWidth(15); + const result = renderModal(); + + fireEvent.click(result.getByRole('button', { name: 'Open dialog' })); + + expect(document.body.style.overflow).toBe('visible clip'); + // On , not : scrollbar-gutter does not propagate to the viewport. + expect(document.documentElement.style.scrollbarGutter).toBe('stable'); + + fireEvent.click(result.getByRole('button', { name: 'Close dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe(''); + }); + + it('reserves nothing when the scrollbar takes no layout width', () => { + setScrollbarWidth(0); + const result = renderModal(); + + fireEvent.click(result.getByRole('button', { name: 'Open dialog' })); + + expect(document.body.style.overflow).toBe('visible clip'); + expect(document.documentElement.style.scrollbarGutter).toBe(''); + }); + + it('restores a gutter the host application had already set', () => { + setScrollbarWidth(15); + document.documentElement.style.scrollbarGutter = 'both-edges'; + const result = renderModal(); + + fireEvent.click(result.getByRole('button', { name: 'Open dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe('stable'); + + fireEvent.click(result.getByRole('button', { name: 'Close dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe('both-edges'); + }); + + it('leaves a non-modal dialog out of the lock entirely', () => { + setScrollbarWidth(15); + const result = render( + + + Non-modal title + + , + ); + + expect(result.container.querySelector('dialog')).toHaveAttribute('data-open'); + expect(document.body.style.overflow).toBe(''); + expect(document.documentElement.style.scrollbarGutter).toBe(''); + }); + }); + it('keeps dialog mounted after close when unmountOnClose is false', () => { const result = render( diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts index c2a583630357bc..da909f82ed1a89 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts @@ -1,14 +1,20 @@ type ScrollLockState = { lockCount: number; previousBodyOverflow: string; + previousScrollbarGutter: string; }; const scrollLockStateByDocument = new WeakMap(); /** * Prevents background scrolling while a modal/alert dialog is open by applying - * `overflow: hidden` to ``. The `` element is intentionally left - * untouched so host-application styles on the document element are preserved. + * `overflow: hidden` to ``, and reserves the space the page scrollbar was + * occupying so nothing on the page moves sideways as it disappears. + * + * The gutter has to be reserved on ``: `scrollbar-gutter` does not propagate + * from `` to the viewport the way `overflow` does, so spelling it on `` + * reserves nothing. It is written only when the scrollbar actually takes layout + * width, because `stable` otherwise reserves a gutter the page never had. * * Nested modal dialogs share a single lock via a reference count. */ @@ -19,18 +25,27 @@ export function lockDocumentScroll(targetDocument: Document): void { return; } + const { body, documentElement } = targetDocument; + // Read the scrollbar's layout width before the lock takes it away. Overlay + // scrollbars and unscrollable pages both measure 0, and both want no gutter. + const scrollbarWidth = (targetDocument.defaultView?.innerWidth ?? 0) - documentElement.clientWidth; + scrollLockStateByDocument.set(targetDocument, { lockCount: 1, - previousBodyOverflow: targetDocument.body.style.overflow, + previousBodyOverflow: body.style.overflow, + previousScrollbarGutter: documentElement.style.scrollbarGutter, }); - targetDocument.body.style.overflow = 'visible clip'; + body.style.overflow = 'visible clip'; + if (scrollbarWidth > 0) { + documentElement.style.scrollbarGutter = 'stable'; + } } /** - * Restores the document's scroll behavior by reverting the `overflow` style - * on the `` element to its previous value. This function is typically - * called when a modal/alert dialog is closed. + * Restores the document's scroll behavior by reverting the `overflow` style on the + * `` element and the reserved scrollbar gutter on `` to their previous + * values. This function is typically called when a modal/alert dialog is closed. */ export function unlockDocumentScroll(targetDocument: Document): void { const state = scrollLockStateByDocument.get(targetDocument); @@ -44,5 +59,6 @@ export function unlockDocumentScroll(targetDocument: Document): void { } targetDocument.body.style.overflow = state.previousBodyOverflow; + targetDocument.documentElement.style.scrollbarGutter = state.previousScrollbarGutter; scrollLockStateByDocument.delete(targetDocument); } From 7d77229a9f15d06ed728719f993cb3f8e709ff73 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Mon, 31 Aug 2026 13:12:21 -0700 Subject: [PATCH 2/4] chore: add change file Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc --- ...nents-preview-4e33a94a-d467-47f7-87d9-2714995d6202.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-react-headless-components-preview-4e33a94a-d467-47f7-87d9-2714995d6202.json diff --git a/change/@fluentui-react-headless-components-preview-4e33a94a-d467-47f7-87d9-2714995d6202.json b/change/@fluentui-react-headless-components-preview-4e33a94a-d467-47f7-87d9-2714995d6202.json new file mode 100644 index 00000000000000..156d995471f91f --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-4e33a94a-d467-47f7-87d9-2714995d6202.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: reserve the scrollbar gutter while a modal dialog locks document scroll, so opening a dialog no longer shifts the page", + "packageName": "@fluentui/react-headless-components-preview", + "email": "array.knight@gmail.com", + "dependentChangeType": "patch" +} From 24d31e5cba60dcb5a9f22b95a0eb4a09910d4e9e Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Mon, 7 Sep 2026 09:39:02 -0700 Subject: [PATCH 3/4] fix(react-headless-components-preview): preserve host scrollbar gutters --- .../src/components/Dialog/Dialog.test.tsx | 33 ++++++++++++++++--- .../src/components/Dialog/utils/scroll.ts | 4 ++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx index ee6232e8a777a9..b02cbafb4ffefd 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx @@ -115,7 +115,7 @@ describe('Dialog', () => { }); afterEach(() => { - delete (document.documentElement as Partial).clientWidth; + Reflect.deleteProperty(document.documentElement, 'clientWidth'); document.documentElement.style.removeProperty('scrollbar-gutter'); document.body.style.removeProperty('overflow'); }); @@ -162,18 +162,41 @@ describe('Dialog', () => { expect(document.documentElement.style.scrollbarGutter).toBe(''); }); - it('restores a gutter the host application had already set', () => { + it.each(['stable', 'stable both-edges'])('preserves an inline %s gutter during and after the lock', gutter => { setScrollbarWidth(15); - document.documentElement.style.scrollbarGutter = 'both-edges'; + document.documentElement.style.scrollbarGutter = gutter; const result = renderModal(); fireEvent.click(result.getByRole('button', { name: 'Open dialog' })); - expect(document.documentElement.style.scrollbarGutter).toBe('stable'); + expect(document.documentElement.style.scrollbarGutter).toBe(gutter); fireEvent.click(result.getByRole('button', { name: 'Close dialog' })); - expect(document.documentElement.style.scrollbarGutter).toBe('both-edges'); + expect(document.documentElement.style.scrollbarGutter).toBe(gutter); + }); + + it('preserves a stable both-edges gutter supplied by a stylesheet', () => { + setScrollbarWidth(15); + const stylesheet = document.createElement('style'); + stylesheet.textContent = 'html { scrollbar-gutter: stable both-edges; }'; + document.head.appendChild(stylesheet); + try { + const result = renderModal(); + expect(window.getComputedStyle(document.documentElement).scrollbarGutter).toBe('stable both-edges'); + + fireEvent.click(result.getByRole('button', { name: 'Open dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe(''); + expect(window.getComputedStyle(document.documentElement).scrollbarGutter).toBe('stable both-edges'); + + fireEvent.click(result.getByRole('button', { name: 'Close dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe(''); + expect(window.getComputedStyle(document.documentElement).scrollbarGutter).toBe('stable both-edges'); + } finally { + stylesheet.remove(); + } }); it('leaves a non-modal dialog out of the lock entirely', () => { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts index da909f82ed1a89..20bba06aa5e49d 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts @@ -29,6 +29,8 @@ export function lockDocumentScroll(targetDocument: Document): void { // Read the scrollbar's layout width before the lock takes it away. Overlay // scrollbars and unscrollable pages both measure 0, and both want no gutter. const scrollbarWidth = (targetDocument.defaultView?.innerWidth ?? 0) - documentElement.clientWidth; + const scrollbarGutter = targetDocument.defaultView?.getComputedStyle(documentElement).scrollbarGutter; + const hasStableGutter = scrollbarGutter?.split(/\s+/).includes('stable'); scrollLockStateByDocument.set(targetDocument, { lockCount: 1, @@ -37,7 +39,7 @@ export function lockDocumentScroll(targetDocument: Document): void { }); body.style.overflow = 'visible clip'; - if (scrollbarWidth > 0) { + if (scrollbarWidth > 0 && !hasStableGutter) { documentElement.style.scrollbarGutter = 'stable'; } } From bffa278584502a5982c86a8955a302cdb17bb632 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Tue, 8 Sep 2026 09:34:57 -0700 Subject: [PATCH 4/4] test(react-headless-components-preview): verify scrollbar gutter restoration --- .../library/src/components/Dialog/Dialog.test.tsx | 14 ++++++++++++++ .../library/src/components/Dialog/utils/scroll.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx index b02cbafb4ffefd..e96dd81bda4ff9 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/Dialog.test.tsx @@ -162,6 +162,20 @@ describe('Dialog', () => { expect(document.documentElement.style.scrollbarGutter).toBe(''); }); + it('restores an inline auto gutter after the lock replaces it', () => { + setScrollbarWidth(15); + document.documentElement.style.scrollbarGutter = 'auto'; + const result = renderModal(); + + fireEvent.click(result.getByRole('button', { name: 'Open dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe('stable'); + + fireEvent.click(result.getByRole('button', { name: 'Close dialog' })); + + expect(document.documentElement.style.scrollbarGutter).toBe('auto'); + }); + it.each(['stable', 'stable both-edges'])('preserves an inline %s gutter during and after the lock', gutter => { setScrollbarWidth(15); document.documentElement.style.scrollbarGutter = gutter; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts index 20bba06aa5e49d..4baf591944a951 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dialog/utils/scroll.ts @@ -8,7 +8,7 @@ const scrollLockStateByDocument = new WeakMap(); /** * Prevents background scrolling while a modal/alert dialog is open by applying - * `overflow: hidden` to ``, and reserves the space the page scrollbar was + * `overflow: visible clip` to ``, and reserves the space the page scrollbar was * occupying so nothing on the page moves sideways as it disappears. * * The gutter has to be reserved on ``: `scrollbar-gutter` does not propagate