]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
v6: Dialog fixes (#42353) v6-dev
authorMark Otto <markd.otto@gmail.com>
Thu, 23 Apr 2026 20:38:40 +0000 (13:38 -0700)
committerGitHub <noreply@github.com>
Thu, 23 Apr 2026 20:38:40 +0000 (13:38 -0700)
* Dont auto open toast in overlays dialog example

* switch slide up and down

* dialog animation improvements

* Reflect changes in dialog on Drawer

* Don't let dialogs run into viewport edge on mobile

* Improve dialog swapping

* Fix while in here

* remove .dialog-overflow hackery

* fix bundlewatch

.bundlewatch.config.json
js/src/dialog-base.js
js/src/dialog.js
scss/_dialog.scss
site/src/assets/partials/snippets.js
site/src/content/docs/components/dialog.mdx
site/src/content/docs/components/drawer.mdx
site/src/content/docs/guides/migration.mdx
site/src/scss/_ads.scss

index 3269e7ccbf3dec62b78286515ea5e42153f20dd5..3a39bdd9103f2238ac9c54c965feae116301d173 100644 (file)
@@ -34,7 +34,7 @@
     },
     {
       "path": "./dist/js/bootstrap.bundle.js",
-      "maxSize": "72.5 kB"
+      "maxSize": "72.75 kB"
     },
     {
       "path": "./dist/js/bootstrap.bundle.min.js",
@@ -42,7 +42,7 @@
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "43.5 kB"
+      "maxSize": "44.0 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
index 3716878db7002a48ca270353e3a2040121c9102f..29c66278311b31c2590b5985bf3192f93c24f4af 100644 (file)
@@ -98,10 +98,17 @@ class DialogBase extends BaseComponent {
 
     this._isTransitioning = true
     this._hideElement()
-    this._onAfterHide()
 
     this._queueCallback(() => {
+      // For subclasses that defer close() until the exit transition ends
+      // (so the dialog stays in the top layer with its ::backdrop), close()
+      // happens here instead of in _hideElement().
+      if (this._element.open) {
+        this._closeAndCleanup()
+      }
+
       this._element.classList.remove('hiding')
+      this._onAfterHide()
       this._isTransitioning = false
       EventHandler.trigger(
         this._element,
@@ -163,6 +170,20 @@ class DialogBase extends BaseComponent {
     // Without this, the navbar's `:not([open])` transition-kill rule
     // would prevent the slide-out animation.
     this._element.classList.add('hiding')
+
+    // Subclasses can defer close() until after the exit transition by
+    // returning true from _shouldDeferClose(). This is needed for the
+    // native modal <dialog> centered case: close() removes the dialog
+    // from the top layer immediately, which strips its auto-centering
+    // and the ::backdrop, breaking the exit animation.
+    if (!this._shouldDeferClose()) {
+      this._closeAndCleanup()
+    }
+  }
+
+  // Closes the native <dialog> and tears down body-scroll prevention.
+  // Safe to call multiple times — close() is a no-op on a closed dialog.
+  _closeAndCleanup() {
     this._element.close()
     this._openedAsModal = false
 
@@ -172,6 +193,13 @@ class DialogBase extends BaseComponent {
     }
   }
 
+  // Hook: return true to keep the dialog in the top layer (i.e., delay
+  // calling close()) until the exit transition completes. The base class
+  // closes synchronously; Dialog overrides this for animated modal cases.
+  _shouldDeferClose() {
+    return false
+  }
+
   _triggerBackdropTransition() {
     const hidePreventedEvent = EventHandler.trigger(
       this._element,
index f0eb4ea8354ba7ba137d75b279a0de5371b34053..c87c81445593aa7a1c69c0bbc5209642e73d8685 100644 (file)
@@ -27,6 +27,8 @@ const EVENT_CANCEL = `cancel${EVENT_KEY}`
 const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
 
 const CLASS_NAME_NONMODAL = 'dialog-nonmodal'
+const CLASS_NAME_INSTANT = 'dialog-instant'
+const CLASS_NAME_SWAP_IN = 'dialog-swap-in'
 
 const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dialog"]'
 
@@ -84,6 +86,16 @@ class Dialog extends DialogBase {
     this._element.classList.remove(CLASS_NAME_NONMODAL)
   }
 
+  // Keep the dialog in the top layer until the exit transition ends. This
+  // preserves the browser's modal centering and the native ::backdrop, both
+  // of which disappear synchronously the moment close() is called. Without
+  // this, the dialog would jump to the top of the page and the backdrop
+  // blur would vanish instantly while the dialog faded — making the exit
+  // animation appear to skip entirely.
+  _shouldDeferClose() {
+    return this._isAnimated()
+  }
+
   _onCancel() {
     EventHandler.trigger(this._element, EVENT_CANCEL)
   }
@@ -120,11 +132,37 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (
   const shouldSwap = currentDialog && currentDialog !== target
 
   if (shouldSwap) {
+    // Swap strategy (seamless backdrop, no flash):
+    //   1. Mark the incoming dialog with .dialog-swap-in so its ::backdrop
+    //      skips the @starting-style fade-in and appears fully opaque on
+    //      its very first frame in the top layer.
+    //   2. Open the incoming dialog (showModal).
+    //   3. Close the outgoing dialog synchronously — no exit transition, no
+    //      .hiding — so its ::backdrop is removed in the same frame the
+    //      incoming dialog's backdrop appears. Since both backdrops render
+    //      the same color, the user sees one continuous backdrop. Two
+    //      simultaneously-visible backdrops would composite to ~75% darker,
+    //      and a fading-out + fading-in pair would dip to ~75% opacity —
+    //      either would look like a flash.
+    //   4. Clean up the .dialog-swap-in flag once the incoming dialog
+    //      finishes its entry transition.
     const newDialog = Dialog.getOrCreateInstance(target, config)
+    target.classList.add(CLASS_NAME_SWAP_IN)
     newDialog.show(this)
+    EventHandler.one(target, `shown${EVENT_KEY}`, () => {
+      target.classList.remove(CLASS_NAME_SWAP_IN)
+    })
 
     const currentInstance = Dialog.getInstance(currentDialog)
     if (currentInstance) {
+      // Force synchronous close: .dialog-instant makes _isAnimated() false,
+      // which makes _shouldDeferClose() false, so hide() calls close()
+      // immediately (no deferred .hiding path). The class is removed after
+      // the (now-synchronous) hidden event fires.
+      currentDialog.classList.add(CLASS_NAME_INSTANT)
+      EventHandler.one(currentDialog, EVENT_HIDDEN, () => {
+        currentDialog.classList.remove(CLASS_NAME_INSTANT)
+      })
       currentInstance.hide()
     }
 
index 3e491f822e10a274cda943290ccc3fe4848870b2..b67288c94aad7c77f0561d410873ec692db4c4e1 100644 (file)
@@ -73,7 +73,7 @@ $dialog-sizes: defaults(
     display: flex;
     flex-direction: column;
     width: var(--dialog-width);
-    max-width: 100%;
+    max-width: calc(100% - var(--dialog-margin) * 2);
     max-height: calc(100% - var(--dialog-margin) * 2);
     padding: 0;
     margin: auto;
@@ -100,20 +100,28 @@ $dialog-sizes: defaults(
         visibility 0s var(--dialog-transition-duration)
       );
 
-      // Slide-down variant: enters from above, exits below.
+      // Slide-down variant: enters from above sliding down, exits by reversing
+      // back up. Base value is the entry-from / exit-to position so the
+      // animation works on every open (not just the first, which is the only
+      // time @starting-style applies for a persistent <dialog> element).
       &.dialog-slide-down {
-        transform: translateY(3rem);
+        transform: translateY(-3rem);
       }
 
-      // Slide-up variant: enters from below, exits above.
+      // Slide-up variant: enters from below sliding up, exits by reversing
+      // back down. See note above re: base value choice.
       &.dialog-slide-up {
-        transform: translateY(-3rem);
+        transform: translateY(3rem);
       }
 
       // Open state: visible and faded in.
       // Entry transition: visibility flips visible immediately (0s, no delay),
       // then opacity and transform animate in.
-      &[open] {
+      // The :not(.hiding) qualifier lets the exit transition fall back to the
+      // base "exit" state above while [open] is still present (the JS keeps
+      // the dialog in the top layer during the exit so the ::backdrop and
+      // the browser's modal centering remain intact).
+      &[open]:not(.hiding) {
         overflow: visible;
         visibility: visible;
         opacity: 1;
@@ -125,8 +133,12 @@ $dialog-sizes: defaults(
         transform: none;
       }
 
-      // Static backdrop "bounce" animation (modal dialogs only)
-      &.dialog-static {
+      // Static backdrop "bounce" animation (modal dialogs only). Qualified
+      // with [open] (to outrank the open-state `transform: none` selector
+      // which now also includes `:not(.hiding)`) and `:not(.hiding)` (so
+      // a backdrop click while the dialog is mid-exit doesn't fight the
+      // slide-out transform).
+      &[open].dialog-static:not(.hiding) {
         transform: scale(1.02);
       }
 
@@ -136,6 +148,14 @@ $dialog-sizes: defaults(
         backdrop-filter: blur(var(--dialog-backdrop-blur));
         @include backdrop-transitions(var(--dialog-transition-duration), var(--dialog-transition-timing));
       }
+
+      // Exit: fade the native backdrop out alongside the dialog. The dialog
+      // is kept in the top layer (and thus the ::backdrop is still rendered)
+      // for the duration of the exit transition.
+      &.hiding::backdrop {
+        background-color: transparent;
+        backdrop-filter: blur(0);
+      }
     }
 
     // Instant variant — no transitions, just snap visibility
@@ -146,8 +166,11 @@ $dialog-sizes: defaults(
       }
     }
 
-    // Open state base (always applies, regardless of animation mode)
-    &[open] {
+    // Open state base (always applies, regardless of animation mode).
+    // Excluded while .hiding is present so the animated exit (above) can
+    // fall through to the base "exit" state — for instant dialogs, .hiding
+    // is removed synchronously after close() so this still applies normally.
+    &[open]:not(.hiding) {
       overflow: visible;
       visibility: visible;
       opacity: 1;
@@ -165,37 +188,6 @@ $dialog-sizes: defaults(
       transform: translate(-50%, -50%);
     }
 
-    // Overflow dialog - scrollable viewport container with dialog box inside
-    &.dialog-overflow {
-      // Make dialog element the full-viewport scrollable container
-      position: fixed;
-      inset: 0;
-      width: 100%;
-      max-width: 100%;
-      height: 100%;
-      max-height: 100%;
-      padding: var(--dialog-margin);
-      margin: 0;
-      overflow-y: auto;
-      overscroll-behavior: contain;
-      background: transparent;
-      border: 0;
-      box-shadow: none;
-
-      // The visual dialog box is a child wrapper
-      > .dialog-box {
-        max-width: var(--dialog-width);
-        margin-block-end: var(--dialog-margin);
-        margin-inline: auto;
-        color: var(--dialog-color);
-        background-color: var(--dialog-bg);
-        background-clip: padding-box;
-        border: var(--dialog-border-width) solid var(--dialog-border-color);
-        @include border-radius(var(--dialog-border-radius));
-        @include box-shadow(var(--dialog-box-shadow));
-      }
-    }
-
     // Scrollable dialog body (header/footer stay fixed)
     &.dialog-scrollable[open] {
       max-height: calc(100% - var(--dialog-margin) * 2);
@@ -206,29 +198,27 @@ $dialog-sizes: defaults(
     }
   }
 
-  // Entry animations via @starting-style.
-  // Slide variants need this because the base transform is the EXIT position,
-  // but entry must start from the opposite direction.
-  // ::backdrop needs it since it only exists in the top layer.
-  // Default dialog (fade only) does NOT need @starting-style — the base
-  // opacity: 0 state serves as the entry-from state with visibility trick.
+  // Entry animation for ::backdrop via @starting-style. The backdrop only
+  // exists while the dialog is in the top layer, so its starting state can't
+  // be expressed on the base selector.
+  // Default dialog (fade only) and the slide variants do NOT need
+  // @starting-style — the base opacity: 0 (and base transform for slides)
+  // serves as the entry-from state with the visibility trick.
   @starting-style {
-    // Slide-down: enters from above (negative Y), slides down into view
-    .dialog:not(.dialog-instant).dialog-slide-down[open] {
-      opacity: 0;
-      transform: translateY(-3rem);
-    }
-
-    // Slide-up: enters from below (positive Y), slides up into view
-    .dialog:not(.dialog-instant).dialog-slide-up[open] {
-      opacity: 0;
-      transform: translateY(3rem);
-    }
-
     .dialog:not(.dialog-instant)::backdrop {
       background-color: transparent;
       backdrop-filter: blur(0);
     }
+
+    // Swap entry: when this dialog is opened as the target of a swap, the
+    // outgoing dialog's ::backdrop is being removed synchronously in the same
+    // JS tick. To avoid any flicker (either a dip from a fade-in over nothing,
+    // or double-darkening from two stacked backdrops), start this backdrop
+    // already-opaque so it takes over from the outgoing one seamlessly.
+    .dialog.dialog-swap-in:not(.dialog-instant)::backdrop {
+      background-color: var(--dialog-backdrop-bg);
+      backdrop-filter: blur(var(--dialog-backdrop-blur));
+    }
   }
 
   // Dialog sizes
index 68f965da519a16e3b4ca5a9599c45f98c9450d15..2e7fa5e27564f5dc3a37b88bb8e1f6d206360c64 100644 (file)
@@ -53,8 +53,15 @@ export default () => {
   }
 
   // Instantiate all toasts in docs pages only
+  // Skip toasts inside <dialog> elements; those are shown explicitly
+  // via their own trigger (e.g. the "Show toast" button in the dialog
+  // overlays example) and shouldn't auto-appear when the dialog opens.
   document.querySelectorAll('.bd-example .toast')
     .forEach(toastNode => {
+      if (toastNode.closest('dialog')) {
+        return
+      }
+
       const toast = new Toast(toastNode, {
         autohide: false
       })
index 2272efd3b393c7610fb1b01e2d5bb6899b031ddc..062b1467781a3681488eb358347a518b7ace8e42 100644 (file)
@@ -15,13 +15,11 @@ The Dialog component leverages the browser's native `<dialog>` element, providin
 
 Key features of the native dialog:
 
-- **Native modal behavior** via `showModal()` with automatic focus trapping
-- **Built-in backdrop** using the `::backdrop` pseudo-element
-- **Escape key handling** closes the dialog by default
-- **Accessibility** with proper focus management and ARIA attributes
-- **Top layer rendering** ensures the dialog appears above all other content
-
-Native `<dialog>` elements support two methods: `show()` opens the dialog inline without a backdrop or focus trapping, while `showModal()` opens it as a true modal in the browser's top layer with a backdrop, focus trapping, and Escape key handling. Bootstrap's Dialog component uses `showModal()` to provide the expected modal experience.
+- **Modal or inline** via `showModal()` / `show()` — `modal: true` (default) promotes the dialog to the browser's top layer with a backdrop and focus trapping; `modal: false` renders it inline.
+- **Built-in backdrop** using the `::backdrop` pseudo-element (modal only); set `backdrop: "static"` to lock clicks outside, or `backdrop: false` to hide it.
+- **Escape key handling** closes the dialog by default; set `keyboard: false` to disable.
+- **Accessibility** — focus is trapped inside modal dialogs and returned to the trigger on close, with native `<dialog>` ARIA semantics.
+- **Animated open and close** — circumvent browser restrictions by using a `.hiding` class to keep dialogs in the top layer during close so the exit transition (including `::backdrop`) are animated properly.
 
 <Callout name="info-prefersreducedmotion" />
 
@@ -216,11 +214,9 @@ Add `.dialog-slide-up` to the `<dialog>` and it will slide **up** from the botto
     </div>
   </dialog>`} />
 
-## Scrolling
-
-### Inner scroll
+## Scrollable
 
-You can also create a scrollable dialog that scrolls the dialog body while keeping the header and footer fixed. Add `.dialog-scrollable` to the `.dialog` element.
+Create a scrollable dialog that scrolls the dialog body while keeping the header and footer fixed. Add `.dialog-scrollable` to the `.dialog` element.
 
 <Example code={`<button type="button" class="btn-solid theme-primary" data-bs-toggle="dialog" data-bs-target="#scrollableBodyDialog">
     Launch scrollable body dialog
@@ -247,37 +243,6 @@ You can also create a scrollable dialog that scrolls the dialog body while keepi
     </div>
   </dialog>`} />
 
-### Overflow scroll
-
-For a dialog that extends beyond the viewport and scrolls as a whole, add `.dialog-overflow` and wrap the content in a `.dialog-box` element. The `<dialog>` becomes a full-viewport scrollable container, and the `.dialog-box` is the visual dialog that scrolls up and down.
-
-<Example code={`<button type="button" class="btn-solid theme-primary" data-bs-toggle="dialog" data-bs-target="#overflowDialog">
-    Launch overflow dialog
-  </button>
-
-  <dialog class="dialog dialog-overflow" id="overflowDialog"><!-- [!code highlight] -->
-    <div class="dialog-box"><!-- [!code highlight] -->
-      <div class="dialog-header">
-        <h1 class="dialog-title">Overflow dialog</h1>
-        <CloseButton dismiss="dialog" />
-      </div>
-      <div class="dialog-body">
-        <p>This dialog extends beyond the viewport height. Scroll to see more content. Notice how the entire dialog—including header and footer—shifts up and down as you scroll.</p>
-        <p>The <code>.dialog-overflow</code> modifier creates a full-viewport scrollable container. The <code>.dialog-box</code> wrapper contains the visual dialog that moves up and down as you scroll. This pattern is useful for very long content like terms of service or detailed forms.</p>
-        <p>Unlike the default scrolling behavior where the dialog is constrained to the viewport, overflow dialogs can extend beyond it. This gives users a more document-like reading experience for lengthy content.</p>
-        <p>The backdrop remains fixed while the dialog content scrolls. Clicking outside the dialog box still closes it (unless using a static backdrop). Keyboard navigation and focus trapping work the same as standard dialogs.</p>
-        <p>Consider using <code>.dialog-scrollable</code> instead if you want the header and footer to remain visible while scrolling. The scrollable variant keeps navigation controls accessible at all times, which may be preferable for forms with submit buttons.</p>
-        <p>Both approaches leverage the native <code>&lt;dialog&gt;</code> element's top layer rendering. This ensures the dialog appears above all other content, including elements with high z-index values, fixed positioning, or transforms.</p>
-        <p>The choice between scrolling behaviors depends on your content and user experience goals. Document-like content often works well with overflow scrolling, while interactive content may benefit from fixed header and footer.</p>
-        <p>You've reached the bottom of the overflow dialog!</p>
-      </div>
-      <div class="dialog-footer">
-        <button type="button" class="btn-solid theme-secondary" data-bs-dismiss="dialog">Close</button>
-        <button type="button" class="btn-solid theme-primary">Save changes</button>
-      </div>
-    </div>
-  </dialog>`} />
-
 ## Swapping dialogs
 
 When a toggle trigger is inside an open dialog, clicking it will **swap** dialogs—opening the new one before closing the current. This ensures the backdrop stays visible throughout the transition with no flash. The swap behavior is automatic when a `data-bs-toggle="dialog"` trigger is inside an already-open dialog.
index 901c662855af01db70391f3d8b2e0a09148f7227..bc3e79a6af9419c5409be78b253273c58f2f479c 100644 (file)
@@ -11,11 +11,15 @@ js: required
 
 Drawer builds on the native `<dialog>` element and our Dialog component to manage hidden sidebars via JavaScript. These sidebars, or drawers, can appear from the left, right, top, or bottom edge of the viewport. Buttons or anchors are used as triggers that are attached to specific elements you toggle, and `data` attributes are used to invoke our JavaScript.
 
-- Drawer shares a common base with our [Dialog component]([[docsref:/components/dialog]]), leveraging native `<dialog>` APIs for focus trapping, backdrop, and top-layer rendering.
-- Drawer uses the `<dialog>` element, which must be used instead of a `<div>`.
-- When shown, drawer includes a native `::backdrop` that can be clicked to hide the drawer.
-- Only one drawer can be shown at a time.
-- Due to how CSS handles animations, you cannot use `margin` or `translate` on an `.drawer` element. Instead, use the class as an independent wrapping element.
+- **Drawer shares the same base as [our Dialog component]([[docsref:/components/dialog]])**, using native `<dialog>` APIs for focus trapping, backdrop, and top-layer rendering.
+- **Use the `<dialog>` element**, not a `<div>`. Due to how CSS handles animations, don't apply `margin` or `translate` directly to `.drawer` — wrap it if you need those.
+- **Modal by default, or scroll-through** — the default opens the drawer modally with a backdrop; set `scroll: true` to render without a backdrop and without locking body scroll.
+- **Built-in backdrop** via `::backdrop` (modal only) closes the drawer on click; set `backdrop: "static"` to lock clicks outside, or `backdrop: false` to hide it.
+- **Escape key handling** closes the drawer by default; set `keyboard: false` to disable.
+- **Swipe to dismiss** — drawer auto-wires a placement-aware swipe (swipe left to close `drawer-start` in LTR, swipe down to close `drawer-bottom`, etc.).
+- **Responsive placement** — `.sm-drawer`, `.md-drawer`, and up stay drawer-like below their breakpoint and collapse inline above it, making drawers useful as responsive navbars.
+- **Only one drawer can be shown at a time** (enforced by the data API).
+- **Animated open and close** — drawer slides back out to its placement-specific off-screen position on close, with the `::backdrop` fading alongside it when opened modally. When authoring custom CSS, qualify your `[open]` rules with `:not(.hiding)` so the exit transition can fall through to the base state; add `.drawer-instant` to skip animations.
 
 <Callout name="info-prefersreducedmotion" />
 
index a621cf1a71f991d045fc76355d516a4ce0210d89..41b64da6e97480e3f2357edb22b7195139952a59 100644 (file)
@@ -177,7 +177,7 @@ Bootstrap 6 is a major release with many breaking changes to modernize our codeb
   - CSS variables: `--modal-*` &rarr; `--dialog-*`.
   - Backdrop: The `.modal-backdrop` DOM element is 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 `<html>` element.
-  - 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`, `.dialog-overflow`.
+  - 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.
   - See the [Dialog docs]([[docsref:/components/dialog]]) for full details.
index ba858679ce3b3775f8c663de5dddb117fca4a87d..cfbfcb8c1e8a761086cb16e89ab54451311b3d03 100644 (file)
     border: 0 !important;
     @include border-radius(var(--bs-border-radius-lg) !important);
   }
-  #carbon-responsive a,
-  .carbon-description {
-    color: var(--bs-fg-1) !important;
-  }
+  // #carbon-responsive a,
+  // .carbon-description {
+  //   color: var(--bs-fg-1) !important;
+  // }
   .carbon-img {
     position: relative;
     overflow: hidden;