CSS @starting-style: Entry Animations Without JS Classes
CSS @starting-style lets you animate elements from their initial state on first render — no JS class toggling, no setTimeout hacks. Here's how it actually works.
The Problem You've Been Patching Around
Here's a situation you've probably hit a dozen times. You render an element — a modal, a tooltip, a card — and you want it to fade in from opacity: 0. So you add the element with opacity: 0, then use a setTimeout(..., 16) or toggle a .is-visible class in a requestAnimationFrame callback to trigger the transition. It works. But it's a hack. You're racing the browser's render pipeline and hoping 16ms is enough.
Before 2024, this was the only way. CSS transitions only fire when a property *changes*, so if an element starts life with opacity: 0 and your target state is also opacity: 0, nothing moves. You had to manufacture a state change from the outside. JavaScript became a crutch for what should've been a styling concern all along.
The @starting-style at-rule, shipped in Chrome 117 and Safari 17.5 and now with broad support across all modern browsers, kills that pattern entirely. You declare what a property's value should be before the element's first style is applied — and the browser uses that as the transition's starting point.
In practice, the change feels small until you've used it. Then you realize how many places you've been leaking DOM logic into your JavaScript just to animate entrances.
How @starting-style Actually Works
The core idea: when a browser first renders an element (or makes it visible after display: none), it needs two style snapshots to run a transition — a *before* and an *after*. Normally, there's no "before" on first render. @starting-style gives you that before.
.card {
opacity: 1;
transform: translateY(0);
transition: opacity 300ms ease, transform 300ms ease;
}
@starting-style {
.card {
opacity: 0;
transform: translateY(12px);
}
}That's it. No JavaScript. No class toggling. The moment .card hits the DOM, the browser sees opacity: 0 as the starting value and opacity: 1 as the target, and it runs the transition. The 12px vertical slide is a nice detail — it gives the card a slight upward drift that reads as a natural entry rather than a flat dissolve.
Worth noting: @starting-style only fires on the *first* style computation for an element. Once it's rendered, the rule is ignored. So you don't have to worry about it interfering with hover states, focus rings, or any other transitions you layer on top.
Quick aside: this also works when you toggle display: none back to display: block. Prior to @starting-style, that transition was famously impossible — display isn't animatable and removing it meant the element snapped to visible instantly. Now you can pair this with transition-behavior: allow-discrete (another 2024-era addition) to actually fade elements in and out of display: none.
Real-World Patterns You'll Use Immediately
Modals and dialogs are the obvious first use case. Before, you'd wire up some state, track isOpen, wait a tick, then animate. Now:
.modal {
opacity: 1;
scale: 1;
transition: opacity 250ms ease-out, scale 250ms ease-out;
}
@starting-style {
.modal {
opacity: 0;
scale: 0.95;
}
}The scale: 0.95 trick is subtle but it matters. A flat fade reads as flat. That 5% scale gives the modal a sense of depth, like it's surfacing from slightly behind the screen. Combined with a 250ms ease-out, it feels snappy without being jarring. Honestly, this beats most JavaScript modal animation libraries I've used.
Dropdown menus are another big win. Previously, animating a dropdown that conditionally renders (rather than display: none toggling) required keeping the element in the DOM and mucking around with height or clip-path. With @starting-style, if you're server-rendering or conditionally mounting, the first render gets the animation for free:
.dropdown-menu {
opacity: 1;
transform: translateY(0) scaleY(1);
transform-origin: top center;
transition: opacity 200ms, transform 200ms;
}
@starting-style {
.dropdown-menu {
opacity: 0;
transform: translateY(-8px) scaleY(0.97);
}
}You'll also find this pattern showing up in glassmorphism components where the frosted-glass panel effect looks dramatically better with a smooth entry. A backdrop-blur element that just pops in at full opacity feels wrong — it needs that 200–300ms settle. @starting-style gives you that without a single line of JS.
The display: none Problem — And How to Actually Solve It
There's a longstanding frustration in CSS: you can't animate display. You can animate opacity, transform, height (with some tricks), but not display. So if you're toggling an element between display: none and display: block, the element always snaps — no transition, ever.
Browsers shipped a fix for this in 2024. You can now use transition-behavior: allow-discrete to transition *discrete* properties like display. Pair that with @starting-style and you get full entry *and* exit animations:
.tooltip {
display: block;
opacity: 1;
transition:
opacity 200ms ease,
display 200ms allow-discrete;
}
.tooltip[hidden] {
display: none;
opacity: 0;
}
@starting-style {
.tooltip {
opacity: 0;
}
}The hidden attribute works natively here — you're not managing CSS classes at all. Add hidden to the element to hide it (opacity transitions out first, *then* display: none kicks in), remove hidden to show it (display flips to block and @starting-style provides the fade-in start point). That's the whole thing.
Look, I know this sounds too clean. But it genuinely is. If you've spent time building complex animation systems with Framer Motion or React Spring just to handle entry/exit states, it's worth asking — how much of that could be replaced with 10 lines of CSS? Probably more than you'd think. For simple component libraries, like what you'd find when you browse components, this approach gets you 80% of the way with zero runtime cost.
Browser Support and Progressive Enhancement
As of mid-2026, @starting-style has landed in Chrome 117+, Edge 117+, Firefox 129+, and Safari 17.5+. That's effectively all modern browsers. Global support sits around 92–93% depending on which stat source you're reading. Good enough for production.
The fallback behavior is exactly what you'd want: if a browser doesn't understand @starting-style, it just ignores it. The element renders at its normal styles with no transition. That's a graceful degradation — users on older browsers see the element appear instantly rather than broken. No harm done.
That said, if you need explicit progressive enhancement, you can feature-detect with @supports:
@supports (transition-behavior: allow-discrete) {
.modal {
transition: opacity 250ms ease-out, display 250ms allow-discrete;
}
@starting-style {
.modal {
opacity: 0;
}
}
}One more thing — nesting @starting-style inside another at-rule like @supports or @media works fine. The spec allows it. You can also write @starting-style nested inside the rule itself if you're using modern CSS nesting (Chrome 112+, Firefox 117+):
.card {
opacity: 1;
transition: opacity 300ms ease;
@starting-style {
opacity: 0;
}
}That nested form is cleaner for readability — the starting state lives right next to the element it describes. I'd default to this style for any new project targeting modern browsers.
Combining @starting-style With Modern Design Trends
Entry animations matter most when the entering element is visually significant. A 1px divider appearing doesn't need a transition. A frosted-glass card, a gradient panel, or a heavily styled modal absolutely does. The visual weight of the element sets the expectation for how it should arrive.
If you're building with glassmorphism or neumorphism aesthetics — where surfaces have translucency, blur, and layered depth — entry animations are almost mandatory. A backdrop-filter panel that snaps into existence breaks the illusion of depth. Give it a 300ms fade-in starting at opacity: 0 and blur(0px), and it feels like it's materializing from the background layer.
.glass-panel {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
opacity: 1;
transition: opacity 300ms ease;
@starting-style {
opacity: 0;
}
}The box shadow generator on Empire UI is a good place to experiment with these kinds of panels — you can iterate on the visual style and then drop in @starting-style to handle the entrance. Design and animation in two separate tools, no JS glue between them.
If you're building something with more aggressive visual styling — cyberpunk neon effects, Y2K aesthetics, vaporwave palettes — consider whether your entry animation should match the aesthetic. A cyberpunk modal that slides in from the side with a skewX(-2deg) starting state reads completely differently than a standard fade. @starting-style makes these experimental variants trivial to try.
What This Replaces in Your Stack
Let's be concrete about what you can remove or simplify. If you're using Framer Motion's AnimatePresence exclusively for entry animations on conditionally-rendered elements, @starting-style plus transition-behavior: allow-discrete covers that use case with zero KB of JavaScript. AnimatePresence still wins for complex orchestrated sequences, but for "fade in on mount, fade out on unmount" it's overkill.
React's pattern of setting isVisible state, passing it as a class, and managing the animation from there — gone. In Next.js 14+ with the App Router, where components often render server-side and hydrate without a JavaScript-driven mount lifecycle, this matters more. Your animation isn't waiting on a hydration tick or a useEffect to fire.
That said, @starting-style doesn't replace everything. Exit animations still need either transition-behavior: allow-discrete (for display: none exits) or a JavaScript-managed class that removes the element after the transition ends. For complex stagger animations across lists, CSS alone gets awkward. Tools like Framer Motion or the css-scroll-animations pattern are still the right tool there.
The honest take: @starting-style is one of those features that shows up in your codebase slowly, then everywhere. You use it once for a modal, realize it's better than what you had, then start spotting all the other places you were doing the same setTimeout dance. Six months later you've deleted 200 lines of animation-management JS you didn't need.
FAQ
Only transitions. It defines the before-state for CSS transition properties. If you're using @keyframes animations, the starting frame is already defined in your keyframes — you don't need @starting-style for those.
Yes, if you pair it with transition-behavior: allow-discrete. Each time display switches from none to something visible, @starting-style provides the entry starting point and the transition fires again.
Yes, since Chrome 117 and Firefox 117 support both nesting and @starting-style. You can write it nested inside the parent rule, which keeps the starting state right next to the element's other styles.
The rule is silently ignored. The element renders at its normal computed styles without any entry transition — exactly the graceful fallback you'd want.