@starting-style

[check: …]
📖 Pair this live Tailwind demo with the companion guide (.md) — this page is the rendered ground truth. ↗ Builds on transitions_timing (the 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).

the missing "first frame" — why @starting-style exists

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:nonedisplay:block (the second trick: transition-behavior: allow-discrete).

scenariowithout @starting-stylewith @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:nonedisplay: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)
@starting-style { opacity: 0; /* the FIRST frame this element exists at */ transform: translateY(8px); } /* nested form (preferred, modern CSS nesting): */ .fade-in { opacity: 1; transition: opacity .45s, transform .45s; @starting-style { opacity: 0; transform: translateY(8px); } }

Browser support: Chrome 117+ (Aug 2023), Safari 17.5+ (May 2024), Firefox 129+ (Aug 2024). All evergreen browsers as of 2026-06.

1 · enter animation — WITH vs WITHOUT @starting-style

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.

✅ with @starting-style → fades + slides in

mounted with @starting-style — should fade up from opacity 0.

❌ without @starting-style → snaps in

mounted with NO @starting-style — appears instantly, no animation.

both cards have transition: waiting for mount

2 · exit animation — fading out THEN display:none

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.

✅ exit-fade — allow-discrete on display

visible card — click the button to watch it fade THEN disappear.

❌ exit-broken — no allow-discrete

visible card — click the button: it vanishes INSTANTLY (no fade).
.exit-fade { opacity: 1; transition: opacity .4s ease, display .4s ease allow-discrete; /* ← the magic keyword */ } .exit-fade.exiting { opacity: 0; display: none; /* held until end of transition by allow-discrete */ }

3 · dialog / modal — enter + exit with @starting-style

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.

animated dialog

This dialog used @starting-style on the [open] state and allow-discrete on overlay + display. Esc / click outside / the button to close — the exit is animated too.

dialog[open] { opacity: 1; transform: scale(1); transition: opacity .35s, transform .35s, overlay .35s allow-discrete, /* top-layer exit */ display .35s allow-discrete; @starting-style { opacity: 0; transform: scale(.96) translateY(-24px); } } dialog { /* the closed end-state */ opacity: 0; transform: scale(.96) translateY(-24px); }

intent → pattern

intentpatternwhy
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