Skip to content

Commit 4360821

Browse files
committed
dialog animation improvements
1 parent 4baf1b5 commit 4360821

4 files changed

Lines changed: 68 additions & 13 deletions

File tree

js/src/dialog-base.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,17 @@ class DialogBase extends BaseComponent {
9898

9999
this._isTransitioning = true
100100
this._hideElement()
101-
this._onAfterHide()
102101

103102
this._queueCallback(() => {
103+
// For subclasses that defer close() until the exit transition ends
104+
// (so the dialog stays in the top layer with its ::backdrop), close()
105+
// happens here instead of in _hideElement().
106+
if (this._element.open) {
107+
this._closeAndCleanup()
108+
}
109+
104110
this._element.classList.remove('hiding')
111+
this._onAfterHide()
105112
this._isTransitioning = false
106113
EventHandler.trigger(
107114
this._element,
@@ -163,6 +170,20 @@ class DialogBase extends BaseComponent {
163170
// Without this, the navbar's `:not([open])` transition-kill rule
164171
// would prevent the slide-out animation.
165172
this._element.classList.add('hiding')
173+
174+
// Subclasses can defer close() until after the exit transition by
175+
// returning true from _shouldDeferClose(). This is needed for the
176+
// native modal <dialog> centered case: close() removes the dialog
177+
// from the top layer immediately, which strips its auto-centering
178+
// and the ::backdrop, breaking the exit animation.
179+
if (!this._shouldDeferClose()) {
180+
this._closeAndCleanup()
181+
}
182+
}
183+
184+
// Closes the native <dialog> and tears down body-scroll prevention.
185+
// Safe to call multiple times — close() is a no-op on a closed dialog.
186+
_closeAndCleanup() {
166187
this._element.close()
167188
this._openedAsModal = false
168189

@@ -172,6 +193,13 @@ class DialogBase extends BaseComponent {
172193
}
173194
}
174195

196+
// Hook: return true to keep the dialog in the top layer (i.e., delay
197+
// calling close()) until the exit transition completes. The base class
198+
// closes synchronously; Dialog overrides this for animated modal cases.
199+
_shouldDeferClose() {
200+
return false
201+
}
202+
175203
_triggerBackdropTransition() {
176204
const hidePreventedEvent = EventHandler.trigger(
177205
this._element,

js/src/dialog.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ class Dialog extends DialogBase {
8484
this._element.classList.remove(CLASS_NAME_NONMODAL)
8585
}
8686

87+
// Keep the dialog in the top layer until the exit transition ends. This
88+
// preserves the browser's modal centering and the native ::backdrop, both
89+
// of which disappear synchronously the moment close() is called. Without
90+
// this, the dialog would jump to the top of the page and the backdrop
91+
// blur would vanish instantly while the dialog faded — making the exit
92+
// animation appear to skip entirely.
93+
_shouldDeferClose() {
94+
return this._isAnimated()
95+
}
96+
8797
_onCancel() {
8898
EventHandler.trigger(this._element, EVENT_CANCEL)
8999
}

scss/_dialog.scss

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,11 @@ $dialog-sizes: defaults(
117117
// Open state: visible and faded in.
118118
// Entry transition: visibility flips visible immediately (0s, no delay),
119119
// then opacity and transform animate in.
120-
&[open] {
120+
// The :not(.hiding) qualifier lets the exit transition fall back to the
121+
// base "exit" state above while [open] is still present (the JS keeps
122+
// the dialog in the top layer during the exit so the ::backdrop and
123+
// the browser's modal centering remain intact).
124+
&[open]:not(.hiding) {
121125
overflow: visible;
122126
visibility: visible;
123127
opacity: 1;
@@ -129,8 +133,12 @@ $dialog-sizes: defaults(
129133
transform: none;
130134
}
131135

132-
// Static backdrop "bounce" animation (modal dialogs only)
133-
&.dialog-static {
136+
// Static backdrop "bounce" animation (modal dialogs only). Qualified
137+
// with [open] (to outrank the open-state `transform: none` selector
138+
// which now also includes `:not(.hiding)`) and `:not(.hiding)` (so
139+
// a backdrop click while the dialog is mid-exit doesn't fight the
140+
// slide-out transform).
141+
&[open].dialog-static:not(.hiding) {
134142
transform: scale(1.02);
135143
}
136144

@@ -140,6 +148,14 @@ $dialog-sizes: defaults(
140148
backdrop-filter: blur(var(--dialog-backdrop-blur));
141149
@include backdrop-transitions(var(--dialog-transition-duration), var(--dialog-transition-timing));
142150
}
151+
152+
// Exit: fade the native backdrop out alongside the dialog. The dialog
153+
// is kept in the top layer (and thus the ::backdrop is still rendered)
154+
// for the duration of the exit transition.
155+
&.hiding::backdrop {
156+
background-color: transparent;
157+
backdrop-filter: blur(0);
158+
}
143159
}
144160

145161
// Instant variant — no transitions, just snap visibility
@@ -150,8 +166,11 @@ $dialog-sizes: defaults(
150166
}
151167
}
152168

153-
// Open state base (always applies, regardless of animation mode)
154-
&[open] {
169+
// Open state base (always applies, regardless of animation mode).
170+
// Excluded while .hiding is present so the animated exit (above) can
171+
// fall through to the base "exit" state — for instant dialogs, .hiding
172+
// is removed synchronously after close() so this still applies normally.
173+
&[open]:not(.hiding) {
155174
overflow: visible;
156175
visibility: visible;
157176
opacity: 1;

site/src/content/docs/components/dialog.mdx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,11 @@ The Dialog component leverages the browser's native `<dialog>` element, providin
1515

1616
Key features of the native dialog:
1717

18-
- **Native modal behavior** via `showModal()` with automatic focus trapping
19-
- **Built-in backdrop** using the `::backdrop` pseudo-element
20-
- **Escape key handling** closes the dialog by default
21-
- **Accessibility** with proper focus management and ARIA attributes
22-
- **Top layer rendering** ensures the dialog appears above all other content
23-
24-
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.
18+
- **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.
19+
- **Built-in backdrop** using the `::backdrop` pseudo-element (modal only); set `backdrop: "static"` to lock clicks outside, or `backdrop: false` to hide it.
20+
- **Escape key handling** closes the dialog by default; set `keyboard: false` to disable.
21+
- **Accessibility** — focus is trapped inside modal dialogs and returned to the trigger on close, with native `<dialog>` ARIA semantics.
22+
- **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.
2523

2624
<Callout name="info-prefersreducedmotion" />
2725

0 commit comments

Comments
 (0)