You added @starting-style, your element animates in perfectly, and you feel clever. Then you close it, and it vanishes — no exit animation, just gone. You check the code, everything looks symmetrical, and yet one direction animates and the other doesn't. This is the gotcha, and almost everyone hits it. The good news is that once you understand why it happens, the fix is two extra lines.
First, what @starting-style is for
CSS transitions need two states to animate between: a start and an end. That has always created a specific blind spot. When an element is first added to the page, or first revealed, it has no "before" — the browser has nothing to transition from, so it just appears at its final state with no animation. @starting-style supplies that missing "before." It defines the styles an element should have at the very first moment it renders, giving the transition its starting point.
dialog {
opacity: 1;
transition: opacity 0.3s;
}
@starting-style {
dialog[open] { opacity: 0; }
}
Now the dialog transitions from transparent to opaque when it opens, instead of popping in. So far, so good — and this is exactly where the trap is set.
The gotcha: transitioning away from display: none
The reason the exit doesn't animate is that closing a modal usually means setting display: none, and display has historically not been animatable. It flips instantly. So the moment you close the dialog, display snaps to none, the element is removed from rendering immediately, and your carefully written opacity transition never gets a chance to run. The element is gone before the animation can start.
The entry animation worked only because the element was already displayed when the opacity transition ran. The exit fails because display yanks the element out from under the transition. It isn't that your exit styles are wrong — it's that they never get to play.
The fix: two properties working together
Two newer pieces of CSS exist specifically to close this gap, and you need both.
The first is transition-behavior: allow-discrete. Properties like display are "discrete" — they jump rather than interpolate. This value tells the browser it's allowed to transition them anyway, by holding the old value until the rest of the transition finishes and then flipping. In plain terms: it lets display: none wait for the opacity fade to complete before taking effect.
The second is including display in the transition's property list so it's part of the choreography at all. Put them together and add the transition to the base state so it applies in both directions:
dialog {
opacity: 1;
transition:
opacity 0.3s,
display 0.3s allow-discrete;
}
dialog:not([open]) {
opacity: 0;
}
@starting-style {
dialog[open] { opacity: 0; }
}
Now the exit works. On close, display: none is held back by allow-discrete while opacity fades to zero, and only then does the element leave the render tree. Entry and exit are finally symmetrical.
The related trap: top-layer elements and overlay
There's a companion gotcha for anything shown with showModal() or the Popover API, because those live in the top layer. Elements are removed from the top layer the instant they close, which — like display — cuts your exit animation short even after you've fixed the display issue. The property that governs this is overlay, and it too must be transitioned with allow-discrete so the element stays in the top layer until the animation ends.
dialog {
transition:
opacity 0.3s,
display 0.3s allow-discrete,
overlay 0.3s allow-discrete;
}
The rule to remember
Strip it down and the lesson is short. @starting-style gives you the entry animation, but on its own it lures you into thinking the exit will mirror it — and it won't, because display and overlay snap instead of transitioning. Any time you animate something appearing and disappearing, if it involves display: none or the top layer, both of those properties belong in your transition list with allow-discrete. Write the entry and the exit as a pair from the start, and the gotcha never gets a chance to bite.

