| |

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

UtilityGenerated 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

UtilityPurpose
transition-*Enable the transition
transition-discreteAllow display transition
starting:*Define the starting state
duration-*Set the transition duration
motion-reduce:transition-noneDisable for reduced motion

Common Entry Patterns

PatternStarting Classes
Fade instarting:opacity-0
Slide upstarting:translate-y-4 starting:opacity-0
Slide downstarting:-translate-y-2 starting:opacity-0
Scale instarting:scale-95 starting:opacity-0
Dialogstarting:open:opacity-0 starting:open:scale-95

Browser Support

BrowserMinimum Version
Chrome117+
Firefox129+
Safari17.5+
Edge117+

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

PitfallWhy It HappensFix
No animation on entryMissing transition-*Add transition utility
Display does not animateMissing transition-discreteAdd transition-discrete
Exit animation does not workCSS limitationUse JavaScript for exits
Browser does not supportOlder browserProvide fallback or use JS
Motion causes discomfortNo reduced-motion variantAdd motion-reduce:
Starting style ignoredElement always visibleUse with conditional rendering
Dialog animation not workingMissing open: variantUse 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

ItemValue
Variantstarting:
Generated CSS@starting-style { ... }
PurposeEntry animations without JavaScript
Required for displaytransition-discrete
Dialog patternstarting:open:opacity-0
Popover patternstarting:open:opacity-0
Browser supportChrome 117+, Firefox 129+, Safari 17.5+
Exit animationsNot supported; requires JavaScript
AccessibilityCombine with motion-reduce:
VersionTailwind v4 only

Key takeaways:

  • @starting-style provides the missing “before” state for entry animations. An element that enters the DOM has no previous state to transition from. The starting: 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-style rule. Any utility prefixed with starting: becomes part of the starting state.
  • transition-discrete is required for elements that toggle display. The display property is discrete and cannot be interpolated by default. The transition-discrete utility enables transition-behavior: allow-discrete, which allows the browser to animate the display change.
  • Dialogs and popovers use starting:open:. These elements have an open attribute or a :popover-open pseudo-class when visible. The starting:open: variant targets the starting state only when the element is open.
  • Entry animations are CSS-only; exit animations require JavaScript. The @starting-style rule 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-style requires Chrome 117+, Firefox 129+, or Safari 17.5+. Projects that support older browsers should provide a non-animated fallback or use the JavaScript mounted flag pattern.
  • Combine with motion-reduce: for accessibility. Large entry motion—sliding, scaling—should be disabled when the user has requested reduced motion. The motion-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!