
A Smooth Entry for the Hidden Element
We finally have a native way to animate elements as they are added to the DOM or transitioned from 'display: none' without reaching for JavaScript hacks.
A Smooth Entry for the Hidden Element
I spent an embarrassing amount of my early career convinced I just didn't "get" CSS. I’d build a beautiful modal, set a nice transition: opacity 0.3s ease, and then toggle display: block. And every single time, the element would just... *pop* into existence. No fade, no grace, just a digital jump-scare. I’d try to fix it with setTimeout(() => element.style.opacity = 1, 10) in JavaScript, feeling like a total fraud for using a timer to do a designer's job.
The reality is that display: none has always been the "off" switch for the browser's interpolation engine. You can't transition from "nothing" to "something" because the browser doesn't know what the halfway point between none and block looks like.
But things have changed. We finally have a native CSS way to handle this without reaching for JS hacks or max-height workarounds.
The problem with "Discrete" properties
Properties like display, visibility, or overlay are considered discrete. They don't have middle values. You are either displayed or you aren't.
Until recently, when you changed an element from display: none to display: block, the browser would calculate the layout instantly. Even if you had a transition on opacity, the browser wouldn't "see" the starting state of 0 because, a millisecond prior, the element didn't technically exist in the render tree.
Enter @starting-style
The @starting-style at-rule is the missing link. It allows us to define the styles an element should have the very moment it is rendered in the DOM (or when its display changes from none).
Think of it as a "pre-flight" check. You're telling the browser: "When you wake this element up, assume it starts with these properties, then transition to its actual CSS rules."
A simple fade-in example
Here is how you'd make a div fade in smoothly when it's added to the DOM:
.box {
width: 200px;
height: 200px;
background: #3498db;
opacity: 1;
transition: opacity 0.5s ease-in-out;
/* The magic happens here */
@starting-style {
opacity: 0;
}
}Wait, that's it? For elements being added via JavaScript (like appendChild), yes. The browser sees the box, looks at @starting-style, sees the opacity: 0, and then immediately starts the transition toward the default opacity: 1.
Handling the display: none hurdle
If you aren't adding elements to the DOM but simply toggling display: none on an existing element, you need one more piece of the puzzle: transition-behavior.
By default, transitions don't work on discrete properties. To change that, we use transition-behavior: allow-discrete. This tells the browser to let the display change happen, but to wait for the transition to finish before actually removing the element from the layout.
.card {
display: block;
opacity: 1;
transition:
opacity 0.5s ease,
display 0.5s ease;
transition-behavior: allow-discrete; /* This is crucial */
@starting-style {
opacity: 0;
}
}
.card.is-hidden {
display: none;
opacity: 0;
}Now, when you add the .is-hidden class, the element will fade out over 0.5s before finally switching to display: none. When you remove the class, it uses @starting-style to fade back in. No setTimeout required.
The Popover and Dialog upgrade
This is especially huge for the new popover API and <dialog> elements. These elements go into the "Top Layer," and they traditionally have the same "popping" issue when they open.
You can now style the entry and exit of a native dialog with zero JavaScript logic:
/* The dialog itself */
dialog {
opacity: 1;
transform: scale(1);
transition:
opacity 0.3s ease,
transform 0.3s ease,
display 0.3s ease allow-discrete;
}
/* The state before it opens */
@starting-style {
dialog[open] {
opacity: 0;
transform: scale(0.9);
}
}
/* The state when it's closed (or closing) */
dialog:not([open]) {
opacity: 0;
transform: scale(0.9);
display: none;
}*Note: You can write allow-discrete inside the shorthand transition property as well, which is much cleaner.*
The "Gotchas"
As with all new CSS toys, there are a few things that might trip you up:
1. Browser Support: This is relatively new (Baseline 2024 territory). Chrome, Edge, and Safari are on board. Firefox has added support recently, but always check Can I Use if you're supporting older enterprise browsers.
2. Explicit Display: You must include display in your transition list (or use all) for the allow-discrete behavior to trigger correctly during the exit animation.
3. The "First Paint" quirk: If you want an element to animate on the very first page load, @starting-style is your best friend. It finally treats the initial page render like any other state change.
Why this matters
Before this, we had to choose between performance (removing elements from the DOM/Layout with display: none) and aesthetics (smoothly fading things out). Most developers ended up using opacity: 0 and pointer-events: none, which is a messy compromise that can still mess with screen readers or accidentally catch clicks.
With @starting-style and allow-discrete, we get the best of both worlds. We keep the DOM clean, the accessibility tree happy, and the user interface feeling like it wasn't built in 1998. It’s a small addition to the spec that solves a decade-old headache.


