Skip to content

fix(react-headless-components-preview): reserve the scrollbar gutter while a dialog locks scroll - #36666

Open
Ray Knight (ArrayKnight) wants to merge 2 commits into
microsoft:masterfrom
ArrayKnight:fix/headless-dialog-scrollbar-gutter-36648
Open

fix(react-headless-components-preview): reserve the scrollbar gutter while a dialog locks scroll#36666
Ray Knight (ArrayKnight) wants to merge 2 commits into
microsoft:masterfrom
ArrayKnight:fix/headless-dialog-scrollbar-gutter-36648

Conversation

@ArrayKnight

@ArrayKnight Ray Knight (ArrayKnight) commented Aug 31, 2026

Copy link
Copy Markdown

The headless Dialog's lockDocumentScroll clips body overflow and reserves nothing in the scrollbar's place, so on a page that scrolls, opening a modal dialog removes the scrollbar's layout width and every fixed and centred element shifts sideways for as long as the dialog is open (measured at a 7.5px jump with classic scrollbars; the Griffel Dialog on the same page does not move).

The fix reserves the gutter while the lock is held, with three details that were measured rather than assumed: the scrollbar-gutter write must go on documentElement (overflow propagates from body to the viewport, scrollbar-gutter does not); it must be guarded on the scrollbar actually taking layout width, read before the lock removes it, so pages that never scroll — and overlay-scrollbar environments — do not get the mirror-image shift; and unlock restores the previous inline gutter alongside the previous overflow. Non-modal dialogs never take the lock and are unaffected. Four regression tests are added; the two behavioural ones were verified to fail without the fix.

Note for verification: Puppeteer passes --hide-scrollbars by default, under which this defect cannot reproduce at all — measurements were taken with ignoreDefaultArgs: ['--hide-scrollbars'].

Fixes #36648.

Extracted from #36656 per maintainer request — each in-tree fix from that PR as an isolated change.

…while a dialog locks scroll

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 `<html>` 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 `<html>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant