You’ve written this code. A modal needs to open, you flip display from none to block, you add the .open class to trigger the transition — and nothing animates. The element snaps into existence already at its final state.
So you reach for setTimeout(() => el.classList.add('open'), 0). Or a requestAnimationFrame inside another requestAnimationFrame. Or you pull in a tiny animation library just to handle entry transitions on three components.
There’s a CSS-native fix for this: @starting-style. It’s been Baseline since 2024. And it lets you delete all of it — no libraries, no JavaScript timing hacks, just a native CSS enter animation. CSS :has() deletes three more JavaScript patterns following the same trajectory — CSS features steadily replacing JS workarounds.
The 3-State Model That Makes @starting-style Click
The reason your transition doesn’t fire is that the browser has nothing to transition from. The element was display: none a frame ago — it didn’t exist in layout. The moment you give it a display value, it pops into existence already in its final state. No starting point, no animation.
@starting-style defines that missing starting point. It’s how you animate from display: none natively — the browser renders the element at the starting values for one frame, then transitions to the open state. It tells the browser: “before this element settles into its normal styles, render it like this for one frame.”
Three states are now in play:
@starting-style open state exit
(opacity: 0, → (opacity: 1, → (back to
scale: 0.95) scale: 1) starting,
needs
allow-discrete)
The element flashes through the starting styles for a single frame, then the transition runs to the open state. On the way out, it reverses — but the exit case needs one extra ingredient (transition-behavior: allow-discrete) to hold the element in the DOM long enough for the animation to finish before display: none kicks in.
That’s the whole concept. The rest is syntax — and the migration is shorter than you’d guess.
Before and After: The setTimeout Hack vs. @starting-style
Here’s the hack you’ve been shipping. The element starts at display: none. JS toggles display, then waits a tick so the browser registers the new state, then adds the class that triggers the transition:
function openModal() {
modal.style.display = 'block';
// Wait for the browser to apply display:block
requestAnimationFrame(() => {
requestAnimationFrame(() => {
modal.classList.add('open');
});
});
}
The CSS pairs an .open class with the transition target. The double requestAnimationFrame (or setTimeout(fn, 0)) exists purely because you need the browser to commit the display change before it can transition from one class to the next. It depends on the event loop. It can flicker on slower devices. It breaks the moment a coworker batches your DOM writes through a wrapper.
Now the same thing with @starting-style:
.modal {
opacity: 1;
transform: scale(1);
transition: opacity 200ms, transform 200ms,
display 200ms allow-discrete;
}
.modal:not(.open) {
display: none;
}
@starting-style {
.modal.open {
opacity: 0;
transform: scale(0.95);
}
}
Toggle .open from JS. That’s it. The transition runs because @starting-style gives the browser a starting frame to interpolate from. transition-behavior: allow-discrete (folded into the shorthand here) lets display participate in the transition list so the exit waits for the fade to finish.
Three things to notice. The @starting-style block sits after the original rule — that matters for specificity, and we’ll come back to it. The transition list includes display with allow-discrete. And there is exactly zero JavaScript involved in the animation timing.
What you delete: the requestAnimationFrame dance, the setTimeout(0), the imperative sequencing of “set display, wait, then add class.” It all collapses into a single class toggle.
That’s the pattern. Now let’s apply it to the three components you actually ship.
Three Production Patterns You Can Copy Today
These three cover roughly 80% of the enter animations you’ll write: a modal, a dropdown, and a toast. Each shows the complete CSS, the HTML it attaches to, and the one thing that bites people. Copy these @starting-style examples directly into your codebase.
Pattern 1: Modal Dialog Open Animation
Use the native <dialog> element. The [open] attribute selector handles the show/hide state for free.
dialog {
opacity: 1;
transform: translateY(0);
transition: opacity 250ms, transform 250ms,
overlay 250ms allow-discrete,
display 250ms allow-discrete;
}
@starting-style {
dialog[open] {
opacity: 0;
transform: translateY(20px);
}
}
dialog::backdrop {
background: rgb(0 0 0 / 0.5);
transition: background 250ms,
overlay 250ms allow-discrete,
display 250ms allow-discrete;
}
@starting-style {
dialog[open]::backdrop {
background: rgb(0 0 0 / 0);
}
}
Call dialog.showModal() from JS and the entry animation runs. The overlay property in the transition list is what keeps the dialog rendered in the top layer during the exit animation — without it, the dialog gets yanked out of the top layer the instant you call close() and the transition snaps. The ::backdrop gets its own @starting-style block because nesting @starting-style inside a rule doesn’t apply to pseudo-elements correctly. Write it standalone.
Pattern 2: Dropdown Menu Reveal
Use the popover API if you’re starting fresh — the :popover-open pseudo-class handles state without a single line of JS. CSS anchor positioning handles tooltip and dropdown placement without JavaScript, so you can position this dropdown relative to its trigger entirely in CSS.
[popover] {
opacity: 1;
transform: translateY(0);
transform-origin: top;
transition: opacity 150ms, transform 150ms,
overlay 150ms allow-discrete,
display 150ms allow-discrete;
}
@starting-style {
[popover]:popover-open {
opacity: 0;
transform: translateY(-8px);
}
}
Keep the duration short — 150ms feels responsive. Anything past 200ms and a dropdown starts feeling laggy. The translateY(-8px) is the small downward slide that makes the reveal feel anchored to the trigger; pair it with transform-origin: top if you switch to a scale animation instead. allow-discrete on display is what makes the close animation work — without it, the popover disappears before the fade finishes.
Pattern 3: Toast Notification Entrance
A toast slides in from the right edge of the viewport. The element exists in the DOM (or gets added via JS), and the .visible class toggles its position.
.toast {
transform: translateX(0);
opacity: 1;
transition: transform 300ms cubic-bezier(0.16, 1, 0.3, 1),
opacity 200ms,
display 300ms allow-discrete;
}
.toast:not(.visible) {
display: none;
}
@starting-style {
.toast.visible {
transform: translateX(100%);
opacity: 0;
}
}
Use cubic-bezier easing for the transform — linear makes the toast feel cheap, and the default ease is too symmetric for a slide. The values above are a soft overshoot curve that lands the toast naturally. On exit, the toast translates back to translateX(100%), and allow-discrete holds it in layout until the slide-out completes.
Three patterns, zero JavaScript timing logic. They work today. They’ll work the same way in three years. But there’s one way they can silently break — and if you’ve ever written @starting-style and watched it do nothing, this is why.
The One Gotcha: When @starting-style Silently Fails
@starting-style does NOT get its own specificity layer like @keyframes does. It follows the same specificity rules as any other CSS — which means a more specific selector elsewhere can override your starting styles before they ever get a chance to apply.
If your open-state rule is .modal.open and your @starting-style block targets .modal, the open-state rule wins on specificity and the starting styles never take effect. The transition has nothing to interpolate from, and the element snaps into existence exactly like the bug @starting-style was supposed to fix.
The fix is mechanical: match the specificity of the open-state rule inside your @starting-style block, and place the @starting-style block after the original rule in the cascade. In the modal example above, the open-state rule was dialog[open] and the starting-style selector was also dialog[open] — same specificity, ordered correctly.
Two more things that trip people. Nesting @starting-style inside a rule that includes a pseudo-element like ::backdrop does not apply correctly — write a standalone @starting-style block for pseudo-elements. And DevTools in 2026 still doesn’t show @starting-style values in the computed pane. If your animation isn’t running and the cascade looks fine, check the CSS source directly — the computed view will mislead you.
Once you’ve got those three rules in your head, the failure modes are largely solved. So the only question left is whether you can ship this everywhere — or whether browser support still forces a fallback.
Delete the Hack
In 2026, support is done: Chrome 117+, Firefox 129+, Safari 17.5+, Edge 117+. It’s Baseline 2024. No progressive enhancement caveat needed, no feature query wrapping, no fallback path to maintain. If you support browsers from the last 18 months, you support @starting-style. CSS view transitions handle page-level animations the same way — element-level enter/exit here, page-level navigation there.
The migration is the easiest one you’ll do this year. Open the JS file where your modal, dropdown, or toast lives. Grep for setTimeout near a classList.add. Grep for requestAnimationFrame inside another requestAnimationFrame. Each one is a candidate for the patterns above — and each one you replace deletes more code than it adds. Every CSS transition from hidden to visible can now be a single class toggle with no JavaScript timing. For scroll-linked motion — progress bars, parallax, reveal-on-scroll — CSS scroll-driven animations replace your GSAP scroll triggers.
If you’re on Tailwind 4, the starting-style utilities are already in. If you’re writing raw CSS, the three blocks in this @starting-style tutorial are the whole story.
Next time you open that JS file, grep for setTimeout. You know what to do.