Tailwind CSS 29 🎨 Enter and Exit Animations using @starting-style Variant
An element that appears on screen has no previous state to transition from. It is either in the DOM or it is not. When it enters, it snaps into place. When it leaves, it disappears. CSS transitions cannot animate this because transitions interpolate between two existing states, and the “before” state does not exist for an element that was not rendered. The @starting-style CSS rule and Tailwind’s starting: variant solve this problem. They define the initial styles of an element before it is displayed, giving the browser a state to animate from.
This chapter covers entry animations with the starting: variant in Tailwind v4. You will learn how @starting-style works, how Tailwind exposes it as a variant, how to combine it with transition-discrete for elements that toggle display, and the patterns for dialogs, popovers, toasts, and dropdowns. You will also see the browser support considerations and the limits of what CSS-only entry animations can do.
Key point: The starting: variant applies styles inside a @starting-style rule. These styles are used as the starting point for a transition when the element first appears. Without @starting-style, an element that enters the DOM has no “before” state, and the transition does not run. The variant is Tailwind v4 only, and it requires browser support for the @starting-style CSS rule.
Why @starting-style exists
The missing “before” state. A transition needs two states: the starting state and the ending state. For an element that is already in the DOM and changes on hover, both states exist. For an element that enters the DOM, only the ending state exists. The browser has no record of what the element looked like before it was rendered, because it was not rendered. @starting-style provides that missing state. It tells the browser: “before this element appears, it looks like this.” The transition then animates from that starting style to the element’s actual style.
The display: none problem. Elements that are removed from the layout with display: none have the same problem. When the display changes from none to block, the element appears. There is no transition because the element did not exist in the layout before. @starting-style combined with transition-behavior: allow-discrete enables transitions on display changes. The allow-discrete value tells the browser to animate the discrete display property, and @starting-style provides the starting state.
The JavaScript workaround problem. Before @starting-style, entry animations required JavaScript. The common pattern in React was a mounted state that flipped from false to true in useEffect, triggering a re-render that applied the final classes. This worked but cost an extra render and could flash the final state for one frame before the transition started. @starting-style eliminates the JavaScript entirely. The browser handles the entry animation natively.
The popover and dialog problem. Popovers and dialogs are top-layer elements. They are inserted into the top layer when opened and removed when closed. Animating their entry and exit has historically been difficult. The combination of @starting-style, transition-behavior: allow-discrete, and the overlay property makes it possible to animate these elements smoothly without JavaScript.
The browser support problem. @starting-style is part of the 2024 Baseline. It is supported in Chrome 117+, Firefox 129+, and Safari 17.5+. Tailwind v4’s default browser support is wider than this, so using @starting-style raises the minimum browser version for the elements that use it. The Tailwind documentation notes that @starting-style has “limited browser support” and it is up to the developer whether to use it. For projects that must support older browsers, the JavaScript workaround remains necessary.
a. The starting: variant syntax
Tailwind v4 exposes @starting-style through the starting: variant. Any utility prefixed with starting: is wrapped in a @starting-style rule in the generated CSS.
<div class="opacity-100 transition-opacity duration-300 starting:opacity-0">
Fades in on mount
</div>
The element has opacity-100 as its actual style. The starting:opacity-0 applies opacity: 0 inside a @starting-style rule. When the element is first rendered, the browser uses opacity: 0 as the starting point and transitions to opacity: 100 over 300ms. The transition-opacity and duration-300 utilities enable and configure the transition.
The generated CSS looks like this:
.element {
opacity: 1;
transition: opacity 300ms;
}
@starting-style {
.element {
opacity: 0;
}
}
The variant composes with other variants. starting:open:opacity-0 applies the starting style only when the element is in the open state. This is the pattern for popovers and dialogs, which have an open attribute or a :popover-open pseudo-class when they are visible.
<div popover id="menu" class="opacity-100 starting:open:opacity-0">
Menu content
</div>
Multiple starting: utilities can be applied to the same element. Each one contributes a property to the starting style.
<div class="translate-y-0 opacity-100 transition-all duration-300 starting:translate-y-4 starting:opacity-0">
Slides up while fading in
</div>
The element starts translated down by 1rem and transparent. It transitions to its final position and full opacity. The transition-all covers both properties.
b. transition-discrete and display animations
Elements that toggle display between none and block need an additional piece. The display property is discrete—it cannot be interpolated. A transition on display does nothing by default. The transition-behavior: allow-discrete value enables the transition, and the browser swaps the display value at the 50% point of the transition.
Tailwind provides the transition-discrete utility, which sets transition-behavior: allow-discrete.
<div class="hidden open:block transition-discrete transition-all duration-300 starting:open:opacity-0">
Fades in when opened
</div>
The element is hidden by default and block when the open class is present. The transition-discrete utility enables the display transition. The starting:open:opacity-0 provides the starting opacity. When the open class is applied, the display changes from none to block, and the opacity transitions from 0 to 100.
Without transition-discrete, the element would appear instantly because display cannot be transitioned.
The popover and dialog elements use this pattern natively. A popover with the popover attribute is display: none when closed and display: block when open. A dialog is display: none when closed and display: block when open. Both benefit from transition-discrete combined with starting:open:opacity-0.
c. Patterns for dialogs, popovers, and toasts
Dialog entry animation. A dialog is a top-layer element. It has an open attribute when visible. The starting: variant with the open state provides the entry animation.
<button commandfor="my-dialog" command="show-modal">Open Dialog</button>
<dialog
id="my-dialog"
class="
opacity-100 scale-100 translate-y-0
transition-discrete transition-all duration-300
starting:open:opacity-0
starting:open:scale-95
starting:open:translate-y-4
"
>
<p>Dialog content</p>
<button commandfor="my-dialog" command="close">Close</button>
</dialog>
When the dialog opens, it starts at 0 opacity, 95% scale, and translated down 1rem. It transitions to full opacity, full scale, and its final position. The transition-discrete enables the display transition that moves the dialog from display: none to display: block.
The backdrop can also be animated using arbitrary variants that target the ::backdrop pseudo-element. This requires the arbitrary variant syntax [&::backdrop]:... and starting:[&::backdrop]:....
Popover entry animation. A popover uses the popover attribute and the :popover-open pseudo-class. The starting:open: variant targets the open state.
<button popovertarget="my-popover">Open Popover</button>
<div
popover
id="my-popover"
class="
opacity-100 translate-y-0
transition-discrete transition-all duration-200
starting:open:opacity-0
starting:open:translate-y-2
"
>
Popover content
</div>
The popover fades in and slides down slightly when opened.
Toast entry animation. A toast is a notification that appears in a corner of the screen. It does not toggle display, but it is conditionally rendered. The starting: variant provides the entry animation without JavaScript.
<div
class="
translate-y-0 opacity-100
transition-[transform,opacity] duration-300 ease-out
starting:translate-y-full
starting:opacity-0
"
>
Toast message
</div>
The toast starts translated down (off-screen) and transparent. It slides up and fades in. This replaces the React pattern of a mounted flag in useEffect, eliminating the extra render and the potential one-frame flash of the final state.
Dropdown menu animation. A dropdown that appears below a trigger can use the same pattern.
<ul
class="
translate-y-0 opacity-100
transition-all duration-150
starting:-translate-y-2
starting:opacity-0
"
>
<li>Menu item</li>
</ul>
The dropdown starts slightly above its final position and transparent. It slides down and fades in.
Complete Example Session
<!-- ============================================ -->
<!-- PART 1: BASIC ENTRY FADE -->
<!-- ============================================ -->
<div class="opacity-100 transition-opacity duration-300 starting:opacity-0">
Fades in on mount
</div>
<!-- ============================================ -->
<!-- PART 2: SLIDE AND FADE -->
<!-- ============================================ -->
<div class="translate-y-0 opacity-100 transition-all duration-300 starting:translate-y-4 starting:opacity-0">
Slides up while fading in
</div>
<!-- ============================================ -->
<!-- PART 3: SCALE ENTRY -->
<!-- ============================================ -->
<div class="scale-100 opacity-100 transition-all duration-200 starting:scale-95 starting:opacity-0">
Scales up while fading in
</div>
<!-- ============================================ -->
<!-- PART 4: DIALOG ENTRY -->
<!-- ============================================ -->
<button commandfor="my-dialog" command="show-modal">Open Dialog</button>
<dialog
id="my-dialog"
class="opacity-100 scale-100 transition-discrete transition-all duration-300
starting:open:opacity-0 starting:open:scale-95"
>
<p>Dialog content</p>
</dialog>
<!-- ============================================ -->
<!-- PART 5: POPOVER ENTRY -->
<!-- ============================================ -->
<button popovertarget="my-popover">Open Popover</button>
<div
popover
id="my-popover"
class="opacity-100 translate-y-0 transition-discrete transition-all duration-200
starting:open:opacity-0 starting:open:translate-y-2"
>
Popover content
</div>
<!-- ============================================ -->
<!-- PART 6: TOAST ENTRY -->
<!-- ============================================ -->
<div class="translate-y-0 opacity-100 transition-[transform,opacity] duration-300 ease-out
starting:translate-y-full starting:opacity-0">
Toast message
</div>
<!-- ============================================ -->
<!-- PART 7: DROPDOWN ENTRY -->
<!-- ============================================ -->
<ul class="translate-y-0 opacity-100 transition-all duration-150
starting:-translate-y-2 starting:opacity-0">
<li>Menu item</li>
</ul>
<!-- ============================================ -->
<!-- PART 8: COMBINED WITH MOTION-REDUCE -->
<!-- ============================================ -->
<div class="translate-y-0 opacity-100 transition-all duration-300
starting:translate-y-4 starting:opacity-0
motion-reduce:transition-none motion-reduce:starting:translate-y-0 motion-reduce:starting:opacity-100">
Accessible entry
</div>
<!-- ============================================ -->
<!-- PART 9: DIALOG WITH BACKDROP -->
<!-- ============================================ -->
<dialog
id="my-dialog"
class="opacity-100 transition-discrete transition-all duration-300
starting:open:opacity-0
[&::backdrop]:opacity-100 [&::backdrop]:transition-all [&::backdrop]:duration-300
starting:[&::backdrop]:opacity-0"
>
<p>Dialog with animated backdrop</p>
</dialog>
<!-- ============================================ -->
<!-- PART 10: EXIT ANIMATION (JAVASCRIPT REQUIRED) -->
<!-- ============================================ -->
<!-- CSS @starting-style does not support exit animations.
Exit animations still require JavaScript or the popover/dialog
close behavior to apply a class before removal. -->
The ten parts covered basic entry fade, slide and fade, scale entry, dialog entry, popover entry, toast entry, dropdown entry, motion-reduce combination, dialog with backdrop, and the exit animation limitation.
Quick Reference
starting: Variant
| Utility | Generated CSS |
|---|---|
starting:opacity-0 | @starting-style { opacity: 0 } |
starting:translate-y-4 | @starting-style { translate: 0 1rem } |
starting:scale-95 | @starting-style { scale: 0.95 } |
starting:open:opacity-0 | @starting-style { :open { opacity: 0 } } |
Required Utilities
| Utility | Purpose |
|---|---|
transition-* | Enable the transition |
transition-discrete | Allow display transition |
starting:* | Define the starting state |
duration-* | Set the transition duration |
motion-reduce:transition-none | Disable for reduced motion |
Common Entry Patterns
| Pattern | Starting Classes |
|---|---|
| Fade in | starting:opacity-0 |
| Slide up | starting:translate-y-4 starting:opacity-0 |
| Slide down | starting:-translate-y-2 starting:opacity-0 |
| Scale in | starting:scale-95 starting:opacity-0 |
| Dialog | starting:open:opacity-0 starting:open:scale-95 |
Browser Support
| Browser | Minimum Version |
|---|---|
| Chrome | 117+ |
| Firefox | 129+ |
| Safari | 17.5+ |
| Edge | 117+ |
Best Practices
✅ Do This:
<!-- Use starting: for entry animations -->
<div class="opacity-100 transition-opacity starting:opacity-0"> <!-- ✅ -->
<!-- Use transition-discrete for display toggles -->
<div class="hidden open:block transition-discrete starting:open:opacity-0"> <!-- ✅ -->
<!-- Combine with motion-reduce -->
<div class="starting:opacity-0 motion-reduce:starting:opacity-100"> <!-- ✅ -->
<!-- Keep entry animations short -->
<div class="duration-200 starting:opacity-0"> <!-- ✅ -->
<!-- Use starting:open: for dialogs and popovers -->
<dialog class="starting:open:opacity-0"> <!-- ✅ -->
<!-- Test in browsers without support -->
<!-- Fallback is the element appearing without animation --> <!-- ✅ -->
❌ Don’t Do This:
<!-- Don't forget the transition utility -->
<div class="starting:opacity-0"> <!-- ❌ no transition -->
<!-- Don't use starting: for exit animations -->
<!-- CSS does not support exit animations with @starting-style --> <!-- ❌ -->
<!-- Don't use starting: on elements that are always visible -->
<div class="starting:opacity-0">Always visible</div> <!-- ⚠️ -->
<!-- Don't skip motion-reduce for large motion -->
<div class="starting:translate-y-8"> <!-- ❌ -->
<!-- Don't expect display to transition without transition-discrete -->
<div class="hidden open:block starting:open:opacity-0"> <!-- ❌ no display animation -->
<!-- Don't use starting: in v3 -->
<div class="starting:opacity-0">v3 does not have this</div> <!-- ❌ -->
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| No animation on entry | Missing transition-* | Add transition utility |
| Display does not animate | Missing transition-discrete | Add transition-discrete |
| Exit animation does not work | CSS limitation | Use JavaScript for exits |
| Browser does not support | Older browser | Provide fallback or use JS |
| Motion causes discomfort | No reduced-motion variant | Add motion-reduce: |
| Starting style ignored | Element always visible | Use with conditional rendering |
| Dialog animation not working | Missing open: variant | Use starting:open: |
Real-World Examples
1. Fade In
<div class="opacity-100 transition-opacity duration-300 starting:opacity-0">Content</div>
2. Slide Up
<div class="translate-y-0 opacity-100 transition-all duration-300 starting:translate-y-4 starting:opacity-0">Toast</div>
3. Dialog
<dialog class="opacity-100 scale-100 transition-discrete transition-all duration-300 starting:open:opacity-0 starting:open:scale-95">Dialog</dialog>
4. Popover
<div popover class="opacity-100 translate-y-0 transition-discrete transition-all duration-200 starting:open:opacity-0 starting:open:translate-y-2">Popover</div>
5. Dropdown
<ul class="translate-y-0 opacity-100 transition-all duration-150 starting:-translate-y-2 starting:opacity-0">Menu</ul>
6. Toast
<div class="translate-x-0 transition-transform duration-300 starting:translate-x-full">Notification</div>
7. Modal
<div class="scale-100 opacity-100 transition-all duration-200 starting:scale-95 starting:opacity-0">Modal</div>
8. Backdrop
<dialog class="starting:open:opacity-0 [&::backdrop]:transition-all starting:[&::backdrop]:opacity-0">Dialog</dialog>
9. Reduced Motion
<div class="starting:translate-y-4 motion-reduce:starting:translate-y-0">Accessible</div>
10. Combined with Transition
<div class="transition-transform hover:scale-105 starting:opacity-0">Hover + Entry</div>
Visual
The Missing “Before” State
┌─────────────────────────────────────────────────────────────┐
│ WITHOUT @starting-style │
│ │
│ Element enters DOM │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Final state: opacity: 1 │ │
│ │ No previous state exists. │ │
│ │ Element snaps into place. │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ No animation. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ WITH @starting-style │
│ │
│ Element enters DOM │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Starting state: opacity: 0 │ │
│ │ (defined in @starting-style) │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Final state: opacity: 1 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Transition animates between the two states. │
│ │
└─────────────────────────────────────────────────────────────┘
starting: Variant Flow
┌─────────────────────────────────────────────────────────────┐
│ HTML │
│ <div class="opacity-100 transition-opacity │
│ duration-300 starting:opacity-0"> │
│ │
│ │ │
│ ▼ │
│ Generated CSS │
│ │
│ .element { │
│ opacity: 1; │
│ transition: opacity 300ms; │
│ } │
│ │
│ @starting-style { │
│ .element { │
│ opacity: 0; │
│ } │
│ } │
│ │
│ The variant wraps the utility in @starting-style. │
│ │
└─────────────────────────────────────────────────────────────┘
Dialog Entry Sequence
┌─────────────────────────────────────────────────────────────┐
│ DIALOG CLOSED │
│ display: none │
│ │
│ User clicks Open button │
│ │ │
│ ▼ │
│ @starting-style applies: │
│ opacity: 0, scale: 0.95, translate: 0 1rem │
│ │ │
│ ▼ │
│ display changes to block (allow-discrete) │
│ │ │
│ ▼ │
│ Transition runs over 300ms │
│ │ │
│ ▼ │
│ DIALOG OPEN │
│ opacity: 1, scale: 1, translate: 0 0 │
│ │
└─────────────────────────────────────────────────────────────┘
Entry vs Exit
┌─────────────────────────────────────────────────────────────┐
│ ENTRY (SUPPORTED) │
│ │
│ Element appears │
│ │ │
│ ▼ │
│ @starting-style defines "before" state │
│ │ │
│ ▼ │
│ Transition animates in │
│ │
│ CSS-only. No JavaScript required. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ EXIT (NOT SUPPORTED) │
│ │
│ Element is removed from DOM │
│ │ │
│ ▼ │
│ No "after" state exists. │
│ CSS cannot animate removal. │
│ │
│ Requires JavaScript to delay removal. │
│ Or use popover/dialog close behavior. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Variant | starting: |
| Generated CSS | @starting-style { ... } |
| Purpose | Entry animations without JavaScript |
| Required for display | transition-discrete |
| Dialog pattern | starting:open:opacity-0 |
| Popover pattern | starting:open:opacity-0 |
| Browser support | Chrome 117+, Firefox 129+, Safari 17.5+ |
| Exit animations | Not supported; requires JavaScript |
| Accessibility | Combine with motion-reduce: |
| Version | Tailwind v4 only |
Key takeaways:
@starting-styleprovides the missing “before” state for entry animations. An element that enters the DOM has no previous state to transition from. Thestarting:variant defines that state, and the browser transitions from it to the element’s actual styles.- The
starting:variant is Tailwind v4 only. It wraps utilities in a@starting-stylerule. Any utility prefixed withstarting:becomes part of the starting state. transition-discreteis required for elements that toggledisplay. Thedisplayproperty is discrete and cannot be interpolated by default. Thetransition-discreteutility enablestransition-behavior: allow-discrete, which allows the browser to animate thedisplaychange.- Dialogs and popovers use
starting:open:. These elements have anopenattribute or a:popover-openpseudo-class when visible. Thestarting:open:variant targets the starting state only when the element is open. - Entry animations are CSS-only; exit animations require JavaScript. The
@starting-stylerule defines the state before an element appears. There is no equivalent for the state after an element is removed. Exit animations still need JavaScript to delay the removal or the popover/dialog close behavior. - Browser support is narrower than Tailwind’s default.
@starting-stylerequires Chrome 117+, Firefox 129+, or Safari 17.5+. Projects that support older browsers should provide a non-animated fallback or use the JavaScriptmountedflag pattern. - Combine with
motion-reduce:for accessibility. Large entry motion—sliding, scaling—should be disabled when the user has requested reduced motion. Themotion-reduce:starting:...variant overrides the starting state with a neutral value.
Remember: Entry animations have always been the harder problem in CSS. An element that is already on screen can transition between two states. An element that appears has no “before” state, so there is nothing to transition from. @starting-style provides that state, and Tailwind’s starting: variant exposes it as a composable utility. Combined with transition-discrete for display toggles and the open: variant for dialogs and popovers, it enables entry animations that previously required JavaScript. The trade-off is browser support: @starting-style is newer than Tailwind’s default baseline, so projects that target older browsers must either provide a fallback or accept that the animation is a progressive enhancement. For projects that can support the modern browsers, it eliminates the JavaScript workaround and the one-frame flash that came with it.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!