EdgeCases Logo
Feb 2026
CSS
Deep
7 min read

CSS @starting-style: Entry Animations Without JavaScript

Animate elements appearing from display: none using @starting-style—pure CSS entry transitions without JavaScript timing hacks.

css
@starting-style
transitions
animations
display-none
popover

For years, animating elements appearing from display: none required JavaScript. CSS transitions don't trigger on initial render—the element snaps to its final state instantly. @starting-style fixes this: define where transitions should start from, and CSS handles entry animations natively.

The Problem: Why Transitions Fail on Entry

CSS transitions require a "before" and "after" state. When an element first appears (initial render, or switching from display: none), there's no "before"—the element didn't exist. Browsers skip the transition entirely:

/* ❌ This transition never fires on initial render */
.modal {
  opacity: 1;
  transition: opacity 0.3s;
}

.modal.hidden {
  display: none;
  opacity: 0;
}

The classic workaround: JavaScript to add a class after a requestAnimationFrame, forcing two style recalcs. Fragile and verbose.

The Solution: @starting-style

@starting-style defines the initial values for CSS properties when an element first renders. The browser uses these as the "from" state, enabling true entry transitions:

/* ✅ Entry animation works */
.modal {
  opacity: 1;
  transition: opacity 0.3s;
}

@starting-style {
  .modal {
    opacity: 0;
  }
}

On first render, the modal starts at opacity: 0 (from @starting-style) and transitions to opacity: 1. No JavaScript timing hacks needed.

Nested Syntax

@starting-style can nest inside rulesets, which is cleaner when the selector is complex:

.modal[open] {
  opacity: 1;
  transform: scale(1);
  transition: opacity 0.3s, transform 0.3s;

  @starting-style {
    opacity: 0;
    transform: scale(0.95);
  }
}

Animating display: none

To animate elements toggling display, combine @starting-style with transition-behavior: allow-discrete. This tells the browser to include display in the transition timeline:

.dropdown {
  display: block;
  opacity: 1;
  transition: opacity 0.3s, display 0.3s;
  transition-behavior: allow-discrete;

  @starting-style {
    opacity: 0;
  }
}

.dropdown.hidden {
  display: none;
  opacity: 0;
}

Entry: display switches to block immediately, then opacity transitions from 0 to 1.
Exit: opacity transitions to 0, then display switches to none at 100% progress.

Popovers and Dialogs

@starting-style shines with the Popover API and <dialog> elements, which manage display internally:

[popover]:popover-open {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.25s, transform 0.25s, display 0.25s;
  transition-behavior: allow-discrete;

  @starting-style {
    opacity: 0;
    transform: translateY(-10px);
  }
}

/* Exit transition requires explicit hidden state */
[popover]:not(:popover-open) {
  opacity: 0;
  transform: translateY(-10px);
}

The :popover-open pseudo-class triggers when the popover enters the top layer. Combined with @starting-style, you get smooth entry animations without managing visibility state in JavaScript.

The Three-State Model

When using @starting-style, there are three distinct style states to consider:

  • Starting state: From @starting-style—used only on first render
  • Active state: The element's visible/active styles
  • Default state: The element's hidden/inactive styles
/* Default: hidden */
.toast {
  display: none;
  opacity: 0;
  transform: translateY(100%);
  transition: opacity 0.3s, transform 0.3s, display 0.3s;
  transition-behavior: allow-discrete;
}

/* Active: visible */
.toast.show {
  display: block;
  opacity: 1;
  transform: translateY(0);
}

/* Starting: entry animation origin */
@starting-style {
  .toast.show {
    opacity: 0;
    transform: translateY(100%);
  }
}

Entry: @starting-style → active state.
Exit: active state → default state (not @starting-style—it's only for entry).

Edge Cases and Gotchas

Specificity Matters

@starting-style has the same specificity as the selector inside it. Place it after the main ruleset to ensure it's applied:

/* ❌ Wrong order: starting-style gets overridden */
@starting-style {
  .modal { opacity: 0; }
}
.modal { opacity: 1; }

/* ✅ Correct: starting-style comes after */
.modal { opacity: 1; }
@starting-style {
  .modal { opacity: 0; }
}

Only Works for CSS Transitions

@starting-style has no effect on CSS @keyframes animations. For animations, use animation-fill-mode: backwards or define the start state in the 0%/from keyframe.

DOM Insertion vs Class Toggle

@starting-style triggers when an element first renders matching the selector. If you toggle a class on an already-rendered element, it works. But removing and re-adding the same class in the same frame won't retrigger—the browser optimizes it away.

Real-World Example: Toast Notifications

<div class="toast" id="toast">
  Saved successfully!
</div>
<button onclick="showToast()">Save</button>

<script>
function showToast() {
  const toast = document.getElementById('toast');
  toast.classList.add('show');
  setTimeout(() => toast.classList.remove('show'), 3000);
}
</script>
.toast {
  position: fixed;
  bottom: 1rem;
  right: 1rem;
  padding: 1rem 1.5rem;
  background: #333;
  color: white;
  border-radius: 0.5rem;

  /* Hidden state */
  display: none;
  opacity: 0;
  transform: translateX(100%);

  /* Transition setup */
  transition: opacity 0.3s ease-out,
              transform 0.3s ease-out,
              display 0.3s;
  transition-behavior: allow-discrete;
}

.toast.show {
  display: block;
  opacity: 1;
  transform: translateX(0);
}

@starting-style {
  .toast.show {
    opacity: 0;
    transform: translateX(100%);
  }
}

Browser Support

@starting-style is supported in Chrome 117+, Edge 117+, Safari 17.4+, and Firefox 129+. For older browsers, fall back to JavaScript-based animations or accept instant transitions:

@supports (transition-behavior: allow-discrete) {
  /* Modern entry animations */
  .modal {
    @starting-style {
      opacity: 0;
    }
  }
}

@supports not (transition-behavior: allow-discrete) {
  /* Fallback: no entry animation, just instant display */
}

Key Takeaways

  • @starting-style defines the "from" state for entry transitions—no JavaScript needed
  • Combine with transition-behavior: allow-discrete to animate display changes
  • Perfect for popovers, dialogs, modals, and toasts
  • Remember the three-state model: starting → active ↔ default
  • Place @starting-style after main rulesets for correct specificity

Advertisement

Related Insights

Explore related edge cases and patterns

CSS
Surface
CSS @property: Finally Animating the Un-animatable
6 min
Browser APIs
Surface
HTML Popover API: The Hidden Gotchas
6 min

Advertisement