Here is a pattern almost every frontend developer has written:
el.style.opacity = '0';
el.style.transform = 'translateY(8px)';
document.body.appendChild(el);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.opacity = '1';
el.style.transform = 'translateY(0)';
});
});
Two requestAnimationFrame calls. Both are a hack. The first lets the browser commit the initial state to the render tree; the second triggers the transition. You may know the alternatives — a zero-millisecond setTimeout, a void el.offsetWidth reflow, or adding a class one tick after mount — but all of them are working around the same limitation.
CSS transition interpolates between a before state and an after state. When an element first appears in the DOM, there is no before state — the element simply did not exist. @starting-style was introduced to fill that gap.
What @starting-style does
@starting-style is an at-rule that lets you declare styles for an element before its first rendered frame. The browser uses those styles as the starting point for transitions when the element enters the page.
.notification {
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s ease, transform 0.3s ease;
}
@starting-style {
.notification {
opacity: 0;
transform: translateY(12px);
}
}
When .notification is inserted into the DOM, the browser reads @starting-style for the initial values and immediately transitions to the regular computed styles. The result is a smooth fade-and-slide-up — with zero JavaScript. No double rAF, no class toggle, no forced reflow.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Animating elements out of display: none
The second major use case is toggling visibility with display: none. Setting an element to display: block used to be incompatible with transitions: the element jumped from "not painted" to "painted" in a single frame, giving the transition engine nothing to work with.
With @starting-style and the new allow-discrete keyword, you can animate both the enter and exit:
.drawer {
display: none;
opacity: 0;
transform: translateX(-16px);
transition:
opacity 0.25s ease,
transform 0.25s ease,
display 0.25s allow-discrete;
}
.drawer.open {
display: block;
opacity: 1;
transform: translateX(0);
}
@starting-style {
.drawer.open {
opacity: 0;
transform: translateX(-16px);
}
}
allow-discrete tells the browser that display is being deliberately transitioned: on enter it switches to block immediately so the element is visible during the animation; on exit it holds block until the transition completes before switching to none. Combined with @starting-style for the enter side, you get matching enter and exit animations from purely declarative CSS.
With native popover and <dialog>
@starting-style pairs especially well with the Popover API and <dialog>, which toggle between top-layer states. There's one extra property to handle: overlay, which controls whether the element stays in the top layer while an exit animation runs.
[popover] {
opacity: 0;
transform: scale(0.95);
transition:
opacity 0.2s ease,
transform 0.2s ease,
display 0.2s allow-discrete,
overlay 0.2s allow-discrete;
}
[popover]:popover-open {
opacity: 1;
transform: scale(1);
}
@starting-style {
[popover]:popover-open {
opacity: 0;
transform: scale(0.95);
}
}
Without transitioning overlay, the popover is pulled out of the top layer before the exit transition finishes — it disappears instantly. With allow-discrete on both display and overlay, the element stays rendered and on top until the animation completes, then the browser cleans it up. You get a polished open/close pair with no JavaScript involvement at all.
Browser support and graceful degradation
@starting-style shipped in Chrome 117 (September 2023), Firefox 129 (August 2024), and Safari 17.5 (May 2024). It has broadly available baseline support as of mid-2025.
For older browsers, the @starting-style block is silently ignored — elements appear without an entry animation rather than breaking. That is exactly the right degradation: the animation is an enhancement, not a requirement. You do not need a polyfill.
🧠 Test yourself
Think it clicked? Take the 6-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
The takeaway
The double-rAF trick and the zero-millisecond setTimeout exist because CSS transitions need a "before" state, and there was no native way to provide one for elements entering the DOM. @starting-style is that native way. If your code applies a class one tick after mount, or toggles a data attribute to kick off an enter animation, look at whether @starting-style can replace it. The browser handles the timing correctly by definition; your JavaScript approximation might not.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 📸 Instagram — frontend best practices, daily: @bestpractice___
- 💼 LinkedIn — linkedin.com/in/parsa-jiravand
- ✉️ Email (work & contract inquiries): bestpractice2026@gmail.com
Top comments (0)