✅ with @starting-style → fades + slides in
@starting-style — should fade up from opacity 0.
transition-* family —
@starting-style is what makes them fire on a freshly-mounted element) and
keyframes_animate (run-once vs run-forever animations —
@starting-style is the run-once-on-mount complement to @keyframes).
A CSS transition needs a before value and an after value to
interpolate between. The moment you insert a node into the DOM, it has only an "after" value —
so opacity:1; transition:opacity .3s; on a brand-new node does nothing; the
element just snaps to opacity:1. @starting-style is the
rule that hands the browser the missing "first frame". It is also the only way to animate
display:none → display:block (the second trick:
transition-behavior: allow-discrete).
| scenario | without @starting-style | with @starting-style |
|---|---|---|
freshly inserted node (appendChild, React mount, modal open) |
🔴 snaps — transition has no "before" to interpolate from | 🟢 fades / slides in — browser uses @starting-style values as the first frame |
display:none → display:block |
🔴 instant — display is not animatable by default | 🟢 animates if paired with transition-behavior:allow-discrete on display |
<dialog> open / close |
🔴 appears & disappears with no motion | 🟢 smooth enter + smooth exit (exit needs allow-discrete on overlay too) |
| hover / focus / class-toggle on an already-mounted element | 🟢 works fine — that's what transitions always did | 🟢 not needed (no harm) |
Browser support: Chrome 117+ (Aug 2023), Safari 17.5+ (May 2024), Firefox 129+ (Aug 2024). All evergreen browsers as of 2026-06.
Both cards below use the same transition: opacity .45s, transform .45s.
The only difference: the left one declares an @starting-style block (so the browser
knows the element's first frame is opacity:0); the right one does not. Click
Re-mount to destroy + re-insert each card and watch what happens.
@starting-style — should fade up from opacity 0.
both cards have transition: waiting for mount
Exiting is a different problem. Going to display:none is normally instant —
the element vanishes before the transition can play. The fix:
transition-behavior: allow-discrete (or the shorthand
display .4s allow-discrete) tells the engine to hold the old display value
until the end of the transition, so the fade-out is visible. Click Exit (allow-discrete)
vs Exit (broken) to feel the difference.
Native <dialog> + dialog.showModal() is the canonical use case.
The [open] state declares its @starting-style (the very first frame
after showModal() runs), and the base dialog rule holds the "closed"
end-state. overlay must also be in the transition list with
allow-discrete, otherwise the top-layer exit is instant.
Open it: dialog scales + fades in from above. Close it (Esc / backdrop / button): it scales + fades out. Neither works without @starting-style + allow-discrete.
| intent | pattern | why |
|---|---|---|
| animate a freshly-mounted element | .x { opacity:1; transition:opacity .3s; @starting-style { opacity:0 } } |
gives the browser the missing first frame to interpolate from |
| animate enter + exit on the same element | add display .3s allow-discrete to the transition + a .hidden { display:none } class |
allow-discrete holds display until end of transition so the exit fade is visible |
animate a <dialog> / popover |
transition includes overlay .3s allow-discrete + display .3s allow-discrete |
top-layer removal (overlay) must also be discrete-animated or the exit snaps |
| same idea in Tailwind v4 utility form | @utility enter-fade { …; @starting-style { … } } |
defines a reusable utility; class="enter-fade" behaves like a built-in |
| use it via Tailwind arbitrary variant (no custom utility) | [@starting-style]:opacity-0 |
one-off; Tailwind v4 ships the @starting-style variant out of the box |
| start values for the CLOSED state (going back to none) | NOT needed — @starting-style is only for enter; exit uses allow-discrete |
common confusion: @starting-style is for the ENTER direction only |