entry animations with zero JavaScript
A modal that just appears on open feels abrupt. Fading it in smoothly on first render used to be the territory of JavaScript libraries that had to time the mount moment carefully. With the CSS @starting-style rule, the browser now handles this natively, paired with the transition utilities Tailwind developers already know.
Table of Contents
- 1. The problem with animating an element's first render
- 2. @starting-style basics
- 3. Practical example: fading in a modal with @starting-style
- 4. Combining @starting-style with Tailwind transition utilities
- 5. How this interacts with display: none and allow-discrete
- 6. Comparison to previous JavaScript-based animate-on-mount solutions
- 7. Exit animations and the limits of @starting-style
- 8. Current browser support status for @starting-style
- 9. Best practices for production use
- 10. Summary
- 11. FAQ
1. The problem with animating an element's first render
CSS transitions reliably animate the change between two states, for instance when a class is toggled on click and opacity or transform changes as a result. That breaks down completely, though, for the very first appearance of an element, because a transition by definition needs a starting value and a target value, and an element on first render simply shows up in the DOM already in its final state, with no previous value the browser could animate from.
Until now this was worked around with JavaScript, inserting an element into the DOM with its starting state first, applying the target state on the next animation frame, and letting the browser trigger the transition in between. That works, but it demands careful timing with requestAnimationFrame to avoid race conditions where the browser collapses both states into the same frame and skips the animation entirely.
2. @starting-style basics
The CSS @starting-style rule solves exactly this problem by explicitly defining the state an element should animate from the first time it receives an animatable value, whether that's on first insertion into the DOM or when switching from display: none to a visible display value. Inside the block, the same properties as in the target state are declared, just with their starting values, for example opacity: 0 and a shifted transform, while the regular rule carries the final values.
The browser automatically detects that a transition is defined, reads the starting value from the @starting-style rule, and animates from there to the target value defined in the regular rule as soon as the element becomes visible. This works entirely declaratively with zero JavaScript, because the browser itself manages the critical first frame instead of a script having to reconstruct the timing trick with two consecutive class assignments.
3. Practical example: fading in a modal with @starting-style
A modal dialog is the textbook use case for @starting-style, since it's typically inserted into the DOM via the HTML dialog element or through conditional rendering, and it should fade in smoothly on open rather than just snap into view. In Tailwind, a small, reusable utility combination handles this, animating opacity and scale through a transition while defining the starting values through @starting-style.
In the example below, a modal opens from a slightly shrunk, transparent starting position to its full size and opacity, controlled entirely through CSS. The combination with transition-behavior: allow-discrete matters here, so the jump from display: none to a visible value animates cleanly too, instead of the browser applying the visibility change instantly with no transition at all.
<dialog class="m-auto rounded-xl bg-white p-6 shadow-xl backdrop:bg-black/50
opacity-100 scale-100 transition-all duration-300
starting:opacity-0 starting:scale-95
open:opacity-100 open:scale-100
[transition-behavior:allow-discrete]">
<h2 class="text-lg font-semibold text-gray-900">Confirm your order</h2>
<p class="mt-2 text-sm text-gray-600">
Are you sure you want to place this order?
</p>
<div class="mt-4 flex justify-end gap-2">
<button class="rounded-md border px-3 py-1.5 text-sm">Cancel</button>
<button class="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white">Confirm</button>
</div>
</dialog>
4. Combining @starting-style with Tailwind transition utilities
Tailwind already ships a mature set of transition utilities, such as transition-all, duration-300, or ease-out, and these combine unchanged with @starting-style, since the starting-style rule only defines where the animation begins, while duration, easing, and the animated properties are still controlled through the regular transition utilities. No separate animation API is needed, Tailwind's existing transition system stays fully valid.
In newer Tailwind versions built on CSS-variable-based theming, a custom variant such as starting: can be defined that internally generates the @starting-style rule, letting developers write the starting state directly as a utility class in the markup instead of maintaining a separate CSS file with the @starting-style rule by hand. That keeps the declarative spirit of utility-first consistent even for this new CSS capability.
5. How this interacts with display: none and allow-discrete
A special case involves properties that aren't continuously animatable in the first place, chief among them display, which is either none or a visible value with no in-between states. Normally the browser skips any transition here entirely, since a discrete property change happens instantly with no meaningful intermediate step between none and block, which means a concurrently running opacity transition would run into nothing the moment the element fully leaves the layout.
The CSS value transition-behavior: allow-discrete solves this by letting the browser delay the display change until the rest of the transition finishes, instead of applying it immediately. Combined with @starting-style, this enables a fully animated fade-in and fade-out of an element including the display change itself, something that practically always required JavaScript before, since plain CSS couldn't coordinate the ordering between a display change and an opacity animation.
6. Comparison to previous JavaScript-based animate-on-mount solutions
Libraries like Framer Motion or Alpine.js transition directives have solved the mount-animation problem so far by setting the starting state through a script, applying the target state on the next frame, and letting the actual CSS transition fire in between. That works reliably, but it costs extra JavaScript in the bundle, extra compute for timing the frames, and it makes the animation dependent on the script executing correctly, which can cause animations to simply not happen on slow devices or when the main thread is blocked.
@starting-style moves this logic entirely into the browser's CSS rendering pipeline, guaranteeing the animation starts on the correct frame regardless of how busy the JavaScript main thread happens to be. For Alpine.js-heavy Hyvä projects, that means the x-transition directive can increasingly be replaced by plain CSS with @starting-style for many simple mount animations, cutting the JavaScript payload without giving up the visual result.
7. Exit animations and the limits of @starting-style
@starting-style handles only an element's appearance, not its disappearance, because the rule by definition only kicks in on the transition from a non-rendered or invisible state to a visible one. For hiding an element, such as a modal fading out smoothly on close, a regular transition to the target values combined with allow-discrete for the trailing display change is enough, with no need for a dedicated @starting-style rule in that direction.
A real limitation shows up with elements that toggle between multiple visible states without ever fully leaving the DOM, for example an accordion that switches between open and closed but never becomes fully invisible, since @starting-style doesn't apply there and regular transitions between two defined states remain the right tool. @starting-style is purpose-built for first appearance, not a general-purpose replacement for every kind of state transition.
8. Current browser support status for @starting-style
Chrome and Edge have supported @starting-style since version 117 on Blink, Safari caught up with version 17.5, and Firefox has shipped its implementation as well, so the rule now reaches all three major engine families. As with other newer CSS features, it's worth checking the exact support status on an up-to-date reference site before shipping to production, since version numbers and rollout timing matter differently depending on a project's target audience.
Since @starting-style is purely additive and simply gets ignored when unsupported, a lack of support never breaks the layout, it just means the element appears directly in its target state without a fade-in, matching the previous default behavior. That makes @starting-style a low-risk progressive enhancement that's already worth shipping in production before full browser coverage is reached.
9. Best practices for production use
One important rule of thumb is to keep the @starting-style values deliberately subtle, for example a light scale from 0.95 to 1 instead of a dramatic scale from 0 to 1, since overly strong fade-in effects quickly feel busy and can distract from the actual content. Short transition durations between 150 and 300 milliseconds with a gentle easing function like ease-out consistently deliver the best results for modals, tooltips, and dropdown menus in practice.
@starting-style also shouldn't be applied indiscriminately to every newly rendered element, since too many simultaneous fade-in animations, for instance across a long list of freshly loaded cards, tend to feel visually overwhelming and get in the way of the user's attention rather than supporting it. The rule earns its keep where a single, clearly bounded element such as a modal, a toast, or a dropdown menu deserves attention, and a soft appearance helps frame the context rather than scattering it.
| Aspect | Without @starting-style | With @starting-style | Practical relevance |
|---|---|---|---|
| Starting value on mount | Not definable, element appears instantly in its target state | Explicitly definable through the @starting-style block | Enables fade-in with zero JavaScript |
| display: none transition | Jumps instantly, no transition possible | Delayed until the transition ends with allow-discrete | Required for modals and dropdowns |
| JavaScript requirement | Script needed for the two-frame trick | No script needed, purely declarative | Reduces bundle size and timing risk |
| Browser support | Not relevant, no new feature involved | Chrome/Edge, Safari 17.5+, current Firefox | Progressive enhancement, no fallback code needed |
Mironsoft
Tailwind CSS architecture, design systems, and performance
Tailwind frontends that stay maintainable despite thousands of utility classes?
We review existing Tailwind projects for bloated class lists, inconsistent design tokens, and unused CSS remnants, then build a design system that scales cleanly instead of getting messier with every component.
Design System Review
Checking tokens, spacing scale, and component consistency for maintainability.
Performance Optimization
Systematically reducing CSS bundle size, purge configuration, and load times.
Component Architecture
Building reusable, well-structured components instead of sprawling class lists.
10. Summary
@starting-style at a glance
@starting-style
Defines an element's starting value for a transition the first time it renders.
allow-discrete
Delays the display change until the transition ends, needed for true fade in/out.
No JavaScript
Replaces the two-frame requestAnimationFrame trick with plain, declarative CSS.
Where to use it
Great for modals, toasts and dropdowns, not for general state transitions.