]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Dialog/Drawer: lock scroll on the root element to prevent layout shift (#42545)
authorMark Otto <markd.otto@gmail.com>
Sat, 27 Jun 2026 03:15:07 +0000 (20:15 -0700)
committerGitHub <noreply@github.com>
Sat, 27 Jun 2026 03:15:07 +0000 (20:15 -0700)
* Dialog/Drawer: lock scroll on the root element to prevent layout shift

Apply the dialog-open scroll-lock (overflow: hidden) to the root <html>
element instead of <body>, so it sits on the same element as
scrollbar-gutter: stable. Co-locating them keeps the gutter reserved
while the scrollbar is hidden, so the page no longer shifts (and the
::backdrop covers the gutter instead of leaving a white strip) when a
dialog or drawer opens. Fixes #39221, #39972, #40908, #40659.

* Bump bundlewatch size thresholds

js/src/dialog-base.js
js/tests/unit/dialog.spec.js
js/tests/unit/drawer.spec.js
scss/_dialog.scss
site/src/content/docs/guides/migration.mdx

index 0aaf5ff8e4535dff82da0c341f044d13499e3a43..3601611545626b680ebd03e9988d9b199d8b5b88 100644 (file)
@@ -170,7 +170,11 @@ class DialogBase extends BaseComponent {
     }
 
     if (preventBodyScroll) {
-      document.body.classList.add(CLASS_NAME_OPEN)
+      // Lock scroll on the root element (not <body>) so it lands on the same
+      // element that carries `scrollbar-gutter: stable`. Co-locating them keeps
+      // the gutter reserved while the scrollbar is hidden, so the page doesn't
+      // shift (and the ::backdrop covers the gutter instead of leaving a strip).
+      document.documentElement.classList.add(CLASS_NAME_OPEN)
     }
   }
 
@@ -192,15 +196,15 @@ class DialogBase extends BaseComponent {
     }
   }
 
-  // Closes the native <dialog> and tears down body-scroll prevention.
+  // Closes the native <dialog> and tears down scroll prevention.
   // Safe to call multiple times — close() is a no-op on a closed dialog.
   _closeAndCleanup() {
     this._element.close()
     this._openedAsModal = false
 
-    // Only restore body scroll if no other modal dialogs are open
+    // Only restore scroll if no other modal dialogs are open
     if (!document.querySelector('dialog[open]:modal')) {
-      document.body.classList.remove(CLASS_NAME_OPEN)
+      document.documentElement.classList.remove(CLASS_NAME_OPEN)
     }
   }
 
index 5e6c40773089c61b01c18df0229c9b193049c6a8..223c70f84efa4c0d96760928f2a91b1569fa1c51 100644 (file)
@@ -15,7 +15,7 @@ describe('Dialog', () => {
   afterEach(() => {
     clearFixture()
     clearBodyAndDocument()
-    document.body.classList.remove('dialog-open')
+    document.documentElement.classList.remove('dialog-open')
 
     for (const dialog of document.querySelectorAll('dialog[open]')) {
       dialog.close()
@@ -94,7 +94,7 @@ describe('Dialog', () => {
 
         dialogEl.addEventListener('shown.bs.dialog', () => {
           expect(dialogEl.open).toBeTrue()
-          expect(document.body.classList.contains('dialog-open')).toBeTrue()
+          expect(document.documentElement.classList.contains('dialog-open')).toBeTrue()
           resolve()
         })
 
@@ -251,7 +251,7 @@ describe('Dialog', () => {
 
         dialogEl.addEventListener('hidden.bs.dialog', () => {
           expect(dialogEl.open).toBeFalse()
-          expect(document.body.classList.contains('dialog-open')).toBeFalse()
+          expect(document.documentElement.classList.contains('dialog-open')).toBeFalse()
           resolve()
         })
 
@@ -460,8 +460,8 @@ describe('Dialog', () => {
         dialogEl.addEventListener('shown.bs.dialog', () => {
           expect(dialogEl.open).toBeTrue()
           expect(dialogEl.classList.contains('dialog-nonmodal')).toBeTrue()
-          // Non-modal dialogs should not add dialog-open to body
-          expect(document.body.classList.contains('dialog-open')).toBeFalse()
+          // Non-modal dialogs should not add dialog-open to the root element
+          expect(document.documentElement.classList.contains('dialog-open')).toBeFalse()
           resolve()
         })
 
@@ -1058,7 +1058,7 @@ describe('Dialog', () => {
   })
 
   describe('stacked modals', () => {
-    it('should keep dialog-open on body when closing one of two open modal dialogs', () => {
+    it('should keep dialog-open on the root element when closing one of two open modal dialogs', () => {
       return new Promise(resolve => {
         fixtureEl.innerHTML = [
           '<dialog id="dialog1" class="dialog"></dialog>',
@@ -1075,18 +1075,18 @@ describe('Dialog', () => {
         })
 
         dialog2El.addEventListener('shown.bs.dialog', () => {
-          expect(document.body.classList.contains('dialog-open')).toBeTrue()
+          expect(document.documentElement.classList.contains('dialog-open')).toBeTrue()
           dialog1.hide()
         })
 
         dialog1El.addEventListener('hidden.bs.dialog', () => {
           expect(dialog2El.open).toBeTrue()
-          expect(document.body.classList.contains('dialog-open')).toBeTrue()
+          expect(document.documentElement.classList.contains('dialog-open')).toBeTrue()
           dialog2.hide()
         })
 
         dialog2El.addEventListener('hidden.bs.dialog', () => {
-          expect(document.body.classList.contains('dialog-open')).toBeFalse()
+          expect(document.documentElement.classList.contains('dialog-open')).toBeFalse()
           resolve()
         })
 
index 3cc31cb030108555fb988863c133bc843f7f80b5..f87c10e54ada3eb5f422bc0580d8224a5e393300 100644 (file)
@@ -16,7 +16,7 @@ describe('Drawer', () => {
 
   afterEach(() => {
     clearFixture()
-    document.body.classList.remove('dialog-open')
+    document.documentElement.classList.remove('dialog-open')
     clearBodyAndDocument()
 
     for (const dialog of document.querySelectorAll('dialog[open]')) {
@@ -255,7 +255,7 @@ describe('Drawer', () => {
   })
 
   describe('options', () => {
-    it('if scroll is enabled, should not add dialog-open class to body', () => {
+    it('if scroll is enabled, should not add dialog-open class to the root element', () => {
       return new Promise(resolve => {
         fixtureEl.innerHTML = '<dialog class="drawer"></dialog>'
 
@@ -263,7 +263,7 @@ describe('Drawer', () => {
         const drawer = new Drawer(drawerEl, { scroll: true, backdrop: false })
 
         drawerEl.addEventListener('shown.bs.drawer', () => {
-          expect(document.body.classList.contains('dialog-open')).toBeFalse()
+          expect(document.documentElement.classList.contains('dialog-open')).toBeFalse()
           drawer.hide()
         })
         drawerEl.addEventListener('hidden.bs.drawer', () => {
@@ -273,7 +273,7 @@ describe('Drawer', () => {
       })
     })
 
-    it('if scroll is disabled, should add dialog-open class to body', () => {
+    it('if scroll is disabled, should add dialog-open class to the root element', () => {
       return new Promise(resolve => {
         fixtureEl.innerHTML = '<dialog class="drawer"></dialog>'
 
@@ -281,11 +281,11 @@ describe('Drawer', () => {
         const drawer = new Drawer(drawerEl, { scroll: false })
 
         drawerEl.addEventListener('shown.bs.drawer', () => {
-          expect(document.body.classList.contains('dialog-open')).toBeTrue()
+          expect(document.documentElement.classList.contains('dialog-open')).toBeTrue()
           drawer.hide()
         })
         drawerEl.addEventListener('hidden.bs.drawer', () => {
-          expect(document.body.classList.contains('dialog-open')).toBeFalse()
+          expect(document.documentElement.classList.contains('dialog-open')).toBeFalse()
           resolve()
         })
         drawer.show()
index 74f9000768bb8a2349e7c026d510e9449c2c9009..ee2b36e4001f3b2ff759849cd483de7e044c9aea 100644 (file)
@@ -59,8 +59,11 @@ $dialog-sizes: defaults(
 // scss-docs-end dialog-sizes
 
 @layer components {
-  // Prevent body scroll when dialog is open
-  .dialog-open {
+  // Prevent page scroll when a dialog is open. Applied to the root element so
+  // `overflow: hidden` sits on the same element as `scrollbar-gutter: stable`
+  // (see _root.scss): the gutter stays reserved while the scrollbar is hidden,
+  // so the page doesn't shift when a dialog opens.
+  :root.dialog-open {
     overflow: hidden;
   }
 
index 8c7bad26cb7bfd2f949940f7c71984751379207f..48e9b70193151e877cb9e6d47ba3d525d845c0fe 100644 (file)
@@ -177,7 +177,7 @@ Bootstrap 6 is a major release with many breaking changes to modernize our codeb
   - Data key: `bs.modal` &rarr; `bs.dialog` (affects `Dialog.getInstance()` and `Dialog.getOrCreateInstance()`).
   - CSS variables: `--modal-*` &rarr; `--dialog-*`.
   - Backdrop: The `.modal-backdrop` DOM element and the legacy `util/backdrop` helper are gone — Dialog uses the native `::backdrop` pseudo-element with `backdrop-filter: blur()` support.
-  - Body scroll prevention: `.modal-open` on `<body>` &rarr; `.dialog-open` on the `<body>` element.
+  - Scroll prevention: `.modal-open` on `<body>` &rarr; `.dialog-open` on the root (`<html>`) element, so it pairs with `scrollbar-gutter: stable` and the page doesn't shift when a dialog opens.
   - New variant classes: `.dialog-slide-up`, `.dialog-slide-down` (slide animations), `.dialog-instant` (no animation), `.dialog-static` (static backdrop bounce), `.dialog-nonmodal` (non-modal positioning), `.dialog-scrollable`.
   - Non-modal support: Set `modal: false` or `data-bs-modal="false"` for non-modal dialogs.
   - Dialog swapping: Triggers inside an open dialog can open a new dialog and close the current one automatically.