From 6d9d886644d2d23a76e6237dcf353ca7684169cf Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Tue, 23 Jun 2026 16:38:28 -0700 Subject: [PATCH 1/2] Dialog/Drawer: lock scroll on the root element to prevent layout shift Apply the dialog-open scroll-lock (overflow: hidden) to the root element instead of , 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. --- js/src/dialog-base.js | 12 ++++++++---- js/tests/unit/dialog.spec.js | 18 +++++++++--------- js/tests/unit/drawer.spec.js | 12 ++++++------ scss/_dialog.scss | 7 +++++-- site/src/content/docs/guides/migration.mdx | 2 +- 5 files changed, 29 insertions(+), 22 deletions(-) diff --git a/js/src/dialog-base.js b/js/src/dialog-base.js index 29c66278311b..379b65d448de 100644 --- a/js/src/dialog-base.js +++ b/js/src/dialog-base.js @@ -159,7 +159,11 @@ class DialogBase extends BaseComponent { } if (preventBodyScroll) { - document.body.classList.add(CLASS_NAME_OPEN) + // Lock scroll on the root element (not ) 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) } } @@ -181,15 +185,15 @@ class DialogBase extends BaseComponent { } } - // Closes the native and tears down body-scroll prevention. + // Closes the native 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) } } diff --git a/js/tests/unit/dialog.spec.js b/js/tests/unit/dialog.spec.js index c763abe5d25c..5b10422e070f 100644 --- a/js/tests/unit/dialog.spec.js +++ b/js/tests/unit/dialog.spec.js @@ -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() }) @@ -1036,7 +1036,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 = [ '', @@ -1053,18 +1053,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() }) diff --git a/js/tests/unit/drawer.spec.js b/js/tests/unit/drawer.spec.js index a57cbeb1ab83..d435b1130402 100644 --- a/js/tests/unit/drawer.spec.js +++ b/js/tests/unit/drawer.spec.js @@ -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 = '' @@ -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 = '' @@ -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() diff --git a/scss/_dialog.scss b/scss/_dialog.scss index 74f9000768bb..ee2b36e4001f 100644 --- a/scss/_dialog.scss +++ b/scss/_dialog.scss @@ -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; } diff --git a/site/src/content/docs/guides/migration.mdx b/site/src/content/docs/guides/migration.mdx index 1d5d4cfab95a..c48ab1ba4049 100644 --- a/site/src/content/docs/guides/migration.mdx +++ b/site/src/content/docs/guides/migration.mdx @@ -177,7 +177,7 @@ Bootstrap 6 is a major release with many breaking changes to modernize our codeb - Data key: `bs.modal` → `bs.dialog` (affects `Dialog.getInstance()` and `Dialog.getOrCreateInstance()`). - CSS variables: `--modal-*` → `--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 `` → `.dialog-open` on the `` element. + - Scroll prevention: `.modal-open` on `` → `.dialog-open` on the root (``) 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. From 135736dd2bfc6ead4d13fcae3f70b8c19dda263d Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Thu, 25 Jun 2026 12:50:16 -0700 Subject: [PATCH 2/2] Bump bundlewatch size thresholds --- .bundlewatch.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 2c89f7ea47a8..eb14781be686 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -34,7 +34,7 @@ }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "82.5 kB" + "maxSize": "82.75 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", @@ -42,7 +42,7 @@ }, { "path": "./dist/js/bootstrap.js", - "maxSize": "53.75 kB" + "maxSize": "54.0 kB" }, { "path": "./dist/js/bootstrap.min.js",