There's a class of bug that every CSS developer encounters eventually, usually while trying to animate a toast notification or a dialog. You add the element to the DOM, apply the transition, and… nothing. The element snaps in instantly, ignoring the transition entirely.
The reason is simple once you understand it: CSS transitions animate between two computed states. When an element is first inserted, there is no previous state. The browser has nothing to interpolate from, so the transition is skipped.
The old workarounds
Most of us reached for the same duct-tape solutions. Force a reflow, then set the target state:
// Option 1: setTimeout (fragile, tied to frame timing)
toast.classList.remove('visible');
setTimeout(() => toast.classList.add('visible'), 16);
// Option 2: requestAnimationFrame (better, still manual)
requestAnimationFrame(() => {
requestAnimationFrame(() => toast.classList.add('visible'));
});
// Option 3: force reflow by reading layout property
toast.getBoundingClientRect();
toast.classList.add('visible');
All of these work, in the sense that they produce the animation. None of them feel right. They're coupling timing to the rendering pipeline in ways that are hard to test, hard to reason about, and easy to break with a browser update.
Enter @starting-style
The @starting-style rule, now in Baseline 2024, lets you declare what an element's
computed style should be treated as on its very first render. It gives the browser a
"from" state to animate from, without any JavaScript.
/* The element's normal state */
.toast {
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s ease, transform 0.3s ease;
}
/* How the browser treats this element on its first render */
@starting-style {
.toast {
opacity: 0;
transform: translateY(-0.75rem);
}
}
That's it. Add the element to the DOM, give it the class .toast, and the browser
automatically transitions from the @starting-style declarations to the element's
computed style. No JavaScript timing tricks required.
A real example: dialog enter animation
The <dialog> element has had an exit animation problem for years — but the
enter animation gap was equally painful. With @starting-style, you can animate a
dialog in from a slightly scaled-down state:
dialog {
opacity: 1;
transform: scale(1);
transition:
opacity 0.2s ease,
transform 0.2s ease,
display 0.2s allow-discrete,
overlay 0.2s allow-discrete;
}
dialog:not([open]) {
opacity: 0;
transform: scale(0.95);
display: none;
}
@starting-style {
dialog[open] {
opacity: 0;
transform: scale(0.95);
}
}
Notice the allow-discrete keyword in the transition. That's part of the same
family of features — it lets you transition properties that normally jump (like display
or visibility) instead of snapping them.
What about browser support?
@starting-style shipped in Chrome 117, Edge 117, Firefox 129, and Safari 17.4.
As of early 2026, it's safe to use without a fallback for most cases, since the fallback is simply
"no enter animation" — the element still appears, just without the transition. That's an
acceptable graceful degradation.
If you need to be careful, you can feature-detect:
@supports (selector(:is())) {
/* Use @starting-style here */
@starting-style { ... }
}
Honestly though, I'm just using it. The fallback is fine.
The broader shift here is interesting to watch. Over the past few years, CSS has quietly absorbed whole categories of UI patterns that used to require JavaScript: scroll animations, container queries, anchor positioning, and now this. I don't think JavaScript is going away — but the floor of what's possible without it keeps rising, and that changes how I think about building things.