
The Night I Finally Deleted My 100-Line Transition Hack: A Journey into CSS @starting-style
Discover how the combination of @starting-style and transition-behavior finally allows you to animate elements to and from 'display: none' with pure CSS.
We’ve been told for a decade that display: none is the hard wall of CSS—a binary state that refuses to participate in the fluid beauty of transitions. We accepted this as gospel, layering on heavy JavaScript libraries or writing convoluted "double-requestAnimationFrame" hacks just to make a modal fade in without looking like a glitch in the Matrix. But we were wrong—or rather, the spec finally caught up to our collective frustration.
For years, if you wanted an element to go from display: none to display: block with a smooth opacity fade, you had to play a dangerous game of timing. You’d change the display, force a reflow, and then—only then—trigger the opacity change.
Last night, I deleted every single line of that logic.
The Problem with the "Binary Wall"
CSS transitions require a starting point and an ending point. When an element is display: none, it doesn't exist in the accessibility tree or the render tree. When you switch it to display: block, the browser snaps it into existence instantly. Because there was no "initial" state to transition *from*, it just appeared.
Usually, our "fix" looked something like this mess:
// The old, sad way
el.style.display = 'block';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.opacity = '1';
});
});It worked, but it felt dirty. It felt like we were outsmarting the browser instead of working with it.
Enter @starting-style
The @starting-style rule is the missing piece of the puzzle. It allows you to define the properties an element should have the very moment it is rendered for the first time—or the moment its display changes from none to something else.
Here’s how you actually use it to make a popover fade in:
.modal {
display: block;
opacity: 1;
transition: opacity 0.5s ease-out;
/* This defines the 'before' state */
@starting-style {
opacity: 0;
}
}
/* When hidden */
.modal.hidden {
display: none;
opacity: 0;
}When the .hidden class is removed, the browser looks at @starting-style, sees the opacity should start at 0, and then transitions it to 1. No JavaScript timers. No weird hacks. Just pure, declarative CSS.
The Other Half: Animating the Exit
Animating an element *out* (to display: none) used to be even harder. Even if you transitioned the opacity to 0, the element would still be "there" (blocking clicks) until you manually set display: none at the exact millisecond the animation finished.
Now, we have two new superpowers: allow-discrete and the ability to transition the display property itself.
I know, it sounds fake. "Transitioning the display property?" But look at this:
.card {
transition:
opacity 0.5s ease,
transform 0.5s ease,
display 0.5s allow-discrete; /* The magic sauce */
}
.card.is-deleted {
opacity: 0;
transform: scale(0.9);
display: none;
}By adding allow-discrete to the transition behavior, we are telling the browser: "Keep this element in the render tree until the other transitions (like opacity) are finished, and *then* switch the display to none."
A Practical Example: The "Toast" Notification
Let’s put it all together. Imagine a notification toast that slides up when it appears and fades out when it’s dismissed.
<div class="toast" id="toast">
Successfully deleted 100 lines of code!
</div>.toast {
/* Default shown state */
display: flex;
opacity: 1;
transform: translateY(0);
/* Setup transitions for everything */
transition:
opacity 0.4s ease,
transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275),
display 0.4s allow-discrete,
overlay 0.4s allow-discrete;
/* 1. The Entry Animation */
@starting-style {
opacity: 0;
transform: translateY(20px);
}
}
/* 2. The Exit State */
.toast.hide {
display: none;
opacity: 0;
transform: translateY(-20px);
}Notice the overlay property in the transition list? If you're using the Top Layer API (like the <dialog> element or the Popover API), you need to include overlay in your transition to ensure the element stays in the top layer until the animation finishes.
Why this matters (and the "Gotchas")
Aside from the sheer dopamine hit of deleting JavaScript, this approach is significantly better for performance. The browser can optimize these transitions on the compositor thread.
But wait, there are a few things to keep in mind:
1. Browser Support: This is "Baseline 2024" territory. It’s supported in Chrome 116+, Edge 116+, and Safari 17.4. Firefox is currently lagging slightly behind on allow-discrete but @starting-style is landing in Firefox 129. Always check Can I Use if you're supporting older corporate browsers.
2. The "Display" Logic: Even with allow-discrete, display isn't actually "interpolating." It's not becoming "half-block." It just delays the switch from block to none until the end of the duration.
3. Specifics Matter: You must explicitly list display in your transition-property or use all.
The End of the "Hack" Era
Deleting that old transition utility felt like finally fixing a leaky faucet I’d been ignoring for years. We’ve spent so much time building abstractions to hide CSS limitations that we sometimes forget to check if the limitations still exist.
If you’re working on a modern web app, go check your utils/ folder. If you see a function called wait(), nextTick(), or transitionEndPromise(), it might be time to let @starting-style take over the heavy lifting. Your bundle size—and your sanity—will thank you.


