Animating Popover and Dialog Entry and Exit
Part of CSS @starting-style & Entry Effects in Modern View Transitions & Scroll APIs.
The problem
The <dialog> element and the popover attribute give you correct focus management, correct dismissal behaviour and the top layer for free. What they do not give you is motion — and the first attempt at adding it produces the same result in every codebase: the dialog fades in nicely and then disappears instantly when it closes.
Adding a JavaScript timer to delay the close is the usual next move, and it introduces the class of bug that timers always introduce: the duration is written in two places, they drift apart, and on a slow frame the element is removed while it is still visible.
Root cause: three discrete properties, none of them waiting for you
A top-layer element’s visibility is governed by properties that change discretely rather than continuously.
display flips from block to none with no intermediate values. By default that change applies immediately, and an element with display: none is not rendered — so any transition still running on it is abandoned mid-frame.
overlay represents membership of the top layer. It is browser-controlled, and it also changes discretely. A dialog that closes leaves the top layer at once, which means that for the remainder of any exit animation it renders in normal document order — usually behind the very content it was covering.
content-visibility, where it is involved, behaves the same way.
transition-behavior: allow-discrete changes the timing of all three. With it, a discrete property that is transitioning waits until the other transitions on the element have finished before applying its new value. The fade runs, and only then does display become none and overlay become none.
The entry direction has the mirror-image problem, solved by @starting-style: an element that has just become rendered has no previous computed style, so there is nothing to interpolate from. The starting block supplies it.
Step-by-step resolution
Production code pattern
/* Intent: a modal dialog that fades and lifts in, and reverses out, with the
browser handling every timing decision. */
dialog.sheet {
/* Closed appearance — also the from-state for the exit. */
opacity: 0;
translate: 0 8px;
scale: 0.98;
/* Declared on the element, so it applies opening AND closing. */
transition:
opacity 200ms cubic-bezier(0, 0, 0.2, 1),
translate 200ms cubic-bezier(0, 0, 0.2, 1),
scale 200ms cubic-bezier(0, 0, 0.2, 1),
display 200ms allow-discrete,
overlay 200ms allow-discrete;
}
dialog.sheet[open] {
opacity: 1;
translate: 0 0;
scale: 1;
}
/* The from-state for the entry: without this the dialog is simply there. */
@starting-style {
dialog.sheet[open] {
opacity: 0;
translate: 0 8px;
scale: 0.98;
}
}
/* The backdrop is a separate pseudo-element and needs its own set. */
dialog.sheet::backdrop {
background: rgb(30 27 75 / 0%);
transition: background 200ms linear, display 200ms allow-discrete,
overlay 200ms allow-discrete;
}
dialog.sheet[open]::backdrop { background: rgb(30 27 75 / 42%); }
@starting-style {
dialog.sheet[open]::backdrop { background: rgb(30 27 75 / 0%); }
}
/* Accessibility gate: the dialog still opens and closes correctly; it simply
does so without travel or scale. */
@media (prefers-reduced-motion: reduce) {
dialog.sheet {
translate: 0 0;
scale: 1;
transition:
opacity 100ms linear,
display 100ms allow-discrete,
overlay 100ms allow-discrete;
}
@starting-style {
dialog.sheet[open] { opacity: 0; translate: 0 0; scale: 1; }
}
}
// Intent: the only script required. No timers, no classes, no listeners.
openButton.addEventListener('click', () => dialog.showModal());
closeButton.addEventListener('click', () => dialog.close());
Rendering Impact:
opacity,translateandscale— composite only.displayandoverlayare discrete and deferred, costing one style pass at each end; the backdrop’s colour change is a paint on a full-viewport layer, which is why it is a flat colour rather than a blur.
The backdrop deserves a note. It is tempting to put a backdrop-filter: blur() on it, and it is the single most expensive thing you can animate at viewport scale — see when filter and backdrop-filter are worth the paint cost. A flat translucent colour fades on the compositor; a blur re-samples everything behind it on every frame.
Verification checklist
Constraints and trade-offs
- A long exit keeps a closed dialog in the DOM for its duration; keep exits under about 200 ms so rapid open/close cycles cannot overlap.
allow-discretedefersdisplayonly while other transitions are running — with no other transition, the flip is still immediate.- The backdrop cannot be targeted by most tooling and is easy to forget in a design review; screenshot it deliberately.
- Animating the backdrop with a blur costs a full-viewport re-sample per frame and is the most common reason a dialog feels slow on mobile.
- Engines without
@starting-styleshow the dialog with no entry animation, which is an acceptable degradation as long as the resting styles are the visible ones.
Frequently asked questions
Why does my dialog animate open but vanish instantly on close?
Because display: none applies the moment the state changes, removing the element before the exit transition can run. transition-behavior: allow-discrete defers the display change until the other transitions on the element finish, which makes the exit visible.
What does the overlay property actually do?
It represents membership of the top layer. A closing dialog leaves the top layer as soon as it closes, which can put it behind page content mid-animation. Listing overlay in the transition with allow-discrete keeps it in the top layer until the animation completes.
Do I still need JavaScript for a dialog animation?
Only to open and close it — showModal() and close(), or the popover attributes. Once the CSS above is in place there is no timer to coordinate, no class to toggle after a duration, and no animationend listener.
Related
- CSS @starting-style & Entry Effects — the parent guide on entry transitions and discrete properties
- Using @starting-style for Modal Entry Effects — the entry half in more depth
- When filter and backdrop-filter Are Worth the Paint Cost — why the backdrop is a flat colour here