Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@
},
{
"path": "./dist/js/bootstrap.bundle.js",
"maxSize": "72.5 kB"
"maxSize": "72.75 kB"
},
{
"path": "./dist/js/bootstrap.bundle.min.js",
"maxSize": "51.0 kB"
},
{
"path": "./dist/js/bootstrap.js",
"maxSize": "43.5 kB"
"maxSize": "44.0 kB"
},
{
"path": "./dist/js/bootstrap.min.js",
Expand Down
30 changes: 29 additions & 1 deletion js/src/dialog-base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions js/src/dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"]'

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()
}

Expand Down
108 changes: 49 additions & 59 deletions scss/_dialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
}

Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions site/src/assets/partials/snippets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down
49 changes: 7 additions & 42 deletions site/src/content/docs/components/dialog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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" />

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
Loading
Loading