Adding Pure CSS Entry & Exit Animations to Popovers and Dialogs with `@starting-style`
That jarring pop whenever a popover opens — you've probably found it annoying at least once. And when it closes, it just vanishes. Open up the JS to smooth things out, and you inevitably end up here:
el.classList.add('is-closing');
el.addEventListener('transitionend', () => {
el.classList.remove('is-open', 'is-closing');
}, { once: true });That exact pattern. Toggle a class, register a transitionend listener, wrestle with timing, hit an edge case, and eventually just throw in a timer. I've been down that road more than once.
But you don't have to do that anymore. By combining @starting-style, transition-behavior: allow-discrete, and overlay transitions, you can build complete entry and exit animations for popovers and dialogs in pure CSS — no JavaScript needed. All major browsers — Chrome, Edge, Safari, and Firefox — now support this feature, and with browser support exceeding 86%, using it in production is a realistic choice.
Let's start with the concepts behind why all three techniques are needed together, then walk through ready-to-use code for two scenarios: the Popover API and a <dialog> modal — including prefers-reduced-motion accessibility handling.
Core Concepts
Understanding what problem each technique solves makes the code much easier to read.
| Technique | Role |
|---|---|
@starting-style |
Defines the starting point for entry animations |
transition-behavior: allow-discrete |
Includes discrete properties like display and overlay as transition targets |
overlay transition |
Ensures the element stays in the top-layer until the exit animation finishes |
If any one of the three is missing, either the entry or the exit will break. I spent a good while puzzling over "why does entry work but exit doesn't?" before I understood this.
Why CSS Transitions Alone Never Worked for Entry Animations
CSS transitions work by interpolating between a "previous state" and the "current state." For an element that just appeared from display: none, or was freshly mounted on the page, the browser has no "previous state." No starting point means no transition — hence the abrupt appearance.
@starting-style is the CSS at-rule that solves exactly this problem. It tells the browser: "When this element first appears on screen, start from here."
@starting-style {
[popover]:popover-open {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
}
@starting-style— A CSS at-rule that defines the starting styles for a CSS transition when an element transitions fromdisplay: noneto a visible state, or is rendered for the first time. It applies only to transitions, not CSS animations (@keyframes).
Why transition-behavior: allow-discrete Is Needed
Properties like opacity and transform change continuously, so transitions work fine. The display property, on the other hand, switches instantaneously from none to block — it's a discrete property, and by default it isn't even eligible as a transition target.
Adding transition-behavior: allow-discrete includes these discrete properties as transition targets. It's the key setting that prevents display: none from being applied immediately when a popover closes, which would cut off the exit animation.
The shorthand notation can look unfamiliar at first, but broken down it looks like this:
/* shorthand: property-name duration allow-discrete */
display 0.3s allow-discrete
/* equivalent longhand */
transition-property: display;
transition-duration: 0.3s;
transition-behavior: allow-discrete;Protecting Exit Animations with overlay Transitions
Popovers and dialogs are placed in a special rendering layer called the top-layer, which renders above the normal DOM. If an element is removed from the top-layer while an exit animation is in progress, the animation is cut short.
Adding the overlay property to the transition list tells the browser to keep the element in the top-layer until the exit animation finishes. This property can't be set manually by the user — the browser assigns it automatically to top-layer elements — but by including it as a transition target, you delay its removal.
top-layer — A special rendering layer managed by the browser so that popovers and dialogs can appear above all other elements. It always sits at the very top, independent of
z-index.
Practical Application
Example 1: Popover API Entry & Exit Animation
For anyone new to the HTML popover attribute: adding just the popover attribute hands accessibility, focus management, and Escape key handling over to the browser. Connect a button with popovertarget and you can open and close it without any JS. See the Popover API MDN documentation for more details.
<button popovertarget="my-popover">Open Menu</button>
<div id="my-popover" popover>
<p>Popover content goes here.</p>
</div>[popover] {
/* Positioning — without this, it may render outside the viewport */
margin: auto;
/* Closed-state styles */
opacity: 0;
transform: translateY(-8px) scale(0.96);
transition:
opacity 0.3s ease,
transform 0.3s ease,
display 0.3s allow-discrete, /* ease is meaningless for discrete properties, so omitted */
overlay 0.3s allow-discrete;
}
/* Open state — the target styles for the transition */
[popover]:popover-open {
opacity: 1;
transform: translateY(0) scale(1);
}
/* Entry starting point — animation begins from here on first appearance */
@starting-style {
[popover]:popover-open {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
}
opacityandtransformgetease, butdisplayandoverlaydon't. Discrete transitions likedisplay: none → blockdon't change in steps, so an easing curve has no meaning for them.
| Section | Role |
|---|---|
[popover] base styles |
Closed-state styles + full transition definition |
[popover]:popover-open |
Open state (transition target values), using a CSS pseudo-class |
Inside @starting-style |
Starting values for the entry animation |
display 0.3s allow-discrete |
Prevents display: none from being applied immediately on close |
overlay 0.3s allow-discrete |
Keeps the element in the top-layer during the exit animation |
Example 2: <dialog> Modal + ::backdrop Fade Animation
<dialog> opens with showModal() and closes with close(). The open state is detected via the HTML attribute [open] — it plays the same role as the :popover-open pseudo-class in the Popover API, just with different syntax. One important note: while we're handling the animations in pure CSS, opening and closing <dialog> itself still requires JavaScript.
<button id="open-btn">Open Modal</button>
<dialog id="my-dialog">
<p>Dialog content goes here.</p>
<button id="close-btn">Close</button>
</dialog>
<script>
const dialog = document.getElementById('my-dialog');
document.getElementById('open-btn').addEventListener('click', () => dialog.showModal());
document.getElementById('close-btn').addEventListener('click', () => dialog.close());
</script>dialog {
margin: auto;
opacity: 0;
transform: scale(0.9);
transition:
opacity 0.25s ease,
transform 0.25s ease,
display 0.25s allow-discrete,
overlay 0.25s allow-discrete;
}
dialog[open] {
opacity: 1;
transform: scale(1);
}
@starting-style {
dialog[open] {
opacity: 0;
transform: scale(0.9);
}
}
/* Backdrop fade in/out */
dialog::backdrop {
background-color: rgb(0 0 0 / 0%);
transition:
background-color 0.25s ease,
display 0.25s allow-discrete,
overlay 0.25s allow-discrete;
}
dialog[open]::backdrop {
background-color: rgb(0 0 0 / 50%);
}
@starting-style {
dialog[open]::backdrop {
background-color: rgb(0 0 0 / 0%);
}
}::backdrop is treated as an independent rendered element, so it needs its own @starting-style declaration. Leave it out and you'll get the dialog body fading in nicely while the backdrop pops in abruptly.
Example 3: Accessibility with prefers-reduced-motion
For users with vestibular disorders, excessive motion can cause genuine physical discomfort. The following code provides adequate coverage:
@media (prefers-reduced-motion: reduce) {
[popover],
dialog,
dialog::backdrop {
transition-duration: 0.01ms;
}
}This effectively reduces the transition duration to zero so elements appear instantly. It's just a few lines, but it makes a real difference for affected users.
prefers-reduced-motion— A media query that detects whether the user has enabled "Reduce Motion" in their OS settings. It's recommended to include this whenever you use motion effects.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Pure CSS implementation | No JS class toggling or transitionend event handlers needed — CSS handles everything |
| GPU-accelerated performance | opacity and transform are handled on the compositor layer with no main thread overhead |
| Declarative maintainability | Timing logic is centralized in CSS, reducing the surface area for bugs |
| Native API integration | Works naturally with the Popover API and <dialog> with no extra configuration |
Compositor layer —
opacityandtransformare processed directly on the GPU without triggering layout or paint recalculations. Keeping animations to just these two properties makes it far easier to maintain 60fps.
Drawbacks and Caveats
| Item | Details | Mitigation |
|---|---|---|
<dialog> exit timing |
close() closes immediately by nature, which can cut off the exit animation |
Include both display and overlay in the transition to add a delay |
overlay property behavior |
Cannot be set manually by the user — the browser assigns it automatically to top-layer elements | Only include it in the transition for exit delay purposes; don't set its value directly |
Incompatible with @keyframes |
@starting-style doesn't apply to CSS animations |
Switch from @keyframes to transitions when entry/exit effects are needed |
| Explicit initial styles required | @starting-style won't trigger if its styles are identical to the default values |
Explicitly declare starting values and confirm they differ from the base styles |
| 86% browser support | Roughly 14% of users may see elements appear instantly without animation | Accept instant display as the default behavior, or add a @supports fallback |
The Most Common Mistakes in Practice
-
Omitting
displayandoverlayfrom the transition — If entry works but exit cuts off abruptly, the culprit is almost always these two missing properties. I spent a long time wondering "why does entry work but exit doesn't?" only to find this was the entire issue. Always include them withallow-discrete. -
Writing
@starting-stylewithout the open-state selector — Inside@starting-style, you must use the open-state selector (:popover-open,[open]) exactly as it appears in your regular rules. Writing it against the base element selector ([popover],dialog) won't work. It feels odd to repeat the open-state selector inside@starting-style, but that's how it's structured: it defines the "starting values for when this element transitions into the open state." -
Forgetting
@starting-stylefor::backdrop— Apply it only to the dialog body and miss::backdrop, and the backdrop will appear instantly without a fade.::backdropis an independent rendered element and needs its own@starting-style. The fact that it's a separate element isn't immediately intuitive, and I left it out for a while for exactly that reason.
Wrapping Up
The combination of @starting-style + transition-behavior: allow-discrete + overlay transitions is all you need to build complete entry and exit animations for popovers and dialogs without JavaScript. With all major browsers now on board, there's never been a better time to bring this into production.
Three steps to get started right now:
-
Open the CSS for a popover or dialog in an existing project and add
display 0.3s allow-discrete, overlay 0.3s allow-discreteto thetransitionproperty. Check first that the exit animation no longer cuts off. -
Add a
@starting-style { [selector]:open-state { opacity: 0; transform: ... } }block for the entry animation.opacity: 0combined with a subtletransformis more than enough for a natural-feeling effect. -
Finish with a
@media (prefers-reduced-motion: reduce)block settingtransition-duration: 0.01msto wrap up the accessibility handling. It's two lines of code, but it makes a real difference for actual users.
References
- MDN - @starting-style CSS at-rule
- Chrome for Developers - Four new CSS features for smooth entry and exit animations
- web.dev - Now in Baseline: animating entry effects
- Smashing Magazine - Transitioning Top-Layer Entries And The Display Property In CSS (2025.01)
- LogRocket Blog - Animating dialog and popover elements with CSS @starting-style
- Frontend Masters Blog - The Dialog Element with Entry and Exit Animations
- Josh W. Comeau - The Big Gotcha With @starting-style
- CSS-Tricks - @starting-style
- MDN - transition-behavior CSS property
- MDN - overlay CSS property
- pawelgrzybek.com - Popover element entry and exit animations in a few lines of CSS