CSS @starting-style: Entry Animations for Newly Inserted Elements
AI generated
CSS · Animations · Interactivity · Browser APIs
CSS @starting-style
Entry animations for newly inserted elements

For years, animating an element as it appeared from display:none required JavaScript. CSS @starting-style solves this problem natively, enabling elegant transitions for popovers, dialogs and dynamically inserted content without a single line of JavaScript.

12 min read @starting-style · display:none · Popover · Dialog · discrete transitions Chrome 117+ · Firefox 129+ · Safari 17.4+

1. The problem: why display:none has no transition

CSS transitions work because the browser knows two states of an element and interpolates between them. When an element moves from opacity: 0 to opacity: 1, the browser calculates intermediate values and produces a smooth animation. This principle works flawlessly for all continuous properties such as opacity, transform or color. The display property, however, is discrete: it has no intermediate values. An element is either visible or invisible, with no transition in between.

For years, the classic workaround looked like this: set opacity: 0 and visibility: hidden, start a JavaScript timeout of one frame, then set the class that triggers the entry animation. Or use requestAnimationFrame twice in a row. These solutions are fragile, require JS code that really contains pure presentation logic, and often cause layout flicker. This is exactly where CSS @starting-style comes in: the at-rule defines starting states for properties before a style is applied to an element for the first time, enabling genuine entry animations for elements that previously had no CSS solution.

2. @starting-style: syntax and core principle

The @starting-style at-rule defines styles that apply for exactly one single rendering frame: the very first one in which an element is rendered. The browser compares this starting state with the target state and can calculate a transition between the two. Conceptually, this is like telling the browser: "When this element appears for the very first time, start here." The syntax is nested inside the normal CSS rule, or alternatively written as a standalone block at-rule.

The core principle: @starting-style only applies to the first style calculation of an element. Once the element is already in the DOM and changes afterward, @starting-style no longer plays any role. There are exactly two triggers for @starting-style: an element is newly inserted into the DOM (for example via JavaScript or on the first render of the page), or an element switches from display: none to a visible display value. In both cases, the browser treats the @starting-style declarations as the "before" state of the transition.


/* Basic @starting-style syntax, two equivalent forms */

/* Form 1: Nested inside the target rule */
.toast {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.3s ease, transform 0.3s ease;

  @starting-style {
    /* Starting state: element starts invisible and shifted down */
    opacity: 0;
    transform: translateY(16px);
  }
}

/* Form 2: Standalone block (useful for specificity control) */
@starting-style {
  .toast {
    opacity: 0;
    transform: translateY(16px);
  }
}

/* Works when element is inserted into DOM or transitions from display:none */
.notification-panel {
  display: block;
  opacity: 1;
  scale: 1;
  transition:
    opacity 0.25s ease-out,
    scale 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);

  @starting-style {
    opacity: 0;
    scale: 0.9;
  }
}

One important difference from keyframe animations: @starting-style works with the transition system, not the animation system. That means you get full control over easing, duration and delay via the transition property, without needing a separate @keyframes definition. The exit animation, going from the visible state to display: none, has to be solved differently though, more on that in section 3.

3. Transitioning display:none with allow-discrete

For a transition to or from display: none to happen at all, the transition declaration must include the keyword allow-discrete. Without this keyword, the browser ignores discrete properties such as display and visibility in transitions entirely. Adding allow-discrete tells the browser: "This property should be animated, but it is discrete, treat it accordingly." For discrete properties this means: when appearing, display jumps to the target value immediately at the start of the transition, so the element becomes visible and the actual animation (opacity, transform) can play out. When disappearing, display stays at the visible value until the end of the transition, so the exit animation can play out completely.

This ordering semantic matters: when appearing (none to block), display jumps immediately, and @starting-style supplies the starting state for opacity and so on. When disappearing (block to none), the opacity transition runs first, then display jumps. The interplay of @starting-style, allow-discrete and the transition declaration forms a complete system for bidirectional entry and exit animations, purely in CSS.


/* Bidirectional show/hide animation with allow-discrete */
.dropdown-menu {
  display: none;
  opacity: 0;
  transform: translateY(-8px) scale(0.97);
}

.dropdown-menu.is-open {
  display: block;
  opacity: 1;
  transform: translateY(0) scale(1);
  /* allow-discrete enables discrete property (display) transitions */
  transition:
    opacity 0.2s ease-out,
    transform 0.2s ease-out,
    display 0.2s allow-discrete;
}

/* @starting-style defines entry state when display flips from none to block */
@starting-style {
  .dropdown-menu.is-open {
    opacity: 0;
    transform: translateY(-8px) scale(0.97);
  }
}

/* Exit animation runs automatically when .is-open is removed:
   opacity and transform animate back, display stays block until done,
   then jumps to none, no JavaScript timer needed */

4. The Popover API and @starting-style

The Popover API brings the HTML attribute popover to browsers, letting elements be declaratively marked as a popover. A popover switches between two states: display: none (closed) and a visible display value (open). This exact transition is the ideal use case for @starting-style. The popover is styled in its open state via the pseudo class selector :popover-open.

The @starting-style entry animation for popovers works identically to the general pattern: you define the target state in :popover-open and the starting state in @starting-style { :popover-open }. Important: the browser manages showing and hiding the popover internally, you cannot add your own transition state to the popover element without using the :popover-open pseudo class system. For the exit animation with popovers there is an additional rule: the element leaves the :popover-open state, and the transition away from that state must be defined inside the :popover-open block itself, so the browser is aware of it.

5. Animating the dialog element

The native HTML <dialog> element behaves similarly to a popover: it switches between display: none and a visible state when showModal() or close() is called. The pseudo class :modal targets the open state, and @starting-style defines the entry starting point. A common mistake: the dialog element often has preset browser styles that must be explicitly overridden. In addition, the ::backdrop pseudo element must be animated separately.

The combination of dialog animation and backdrop animation with @starting-style is particularly elegant: both elements can receive independent entry animations that run perfectly in sync, because they are all triggered at the same moment, when the dialog opens. No JavaScript timing, no requestAnimationFrame, no race conditions between backdrop and dialog content.


/* Animated dialog with backdrop using @starting-style */
dialog {
  border: none;
  border-radius: 16px;
  padding: 2rem;
  max-width: min(90vw, 480px);
  box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);

  /* Exit animation: dialog transitions out when :modal is removed */
  transition:
    opacity 0.25s ease,
    transform 0.25s ease,
    overlay 0.25s allow-discrete,
    display 0.25s allow-discrete;

  /* Default (closed) state, also serves as exit target */
  opacity: 0;
  transform: scale(0.95) translateY(16px);
}

dialog:modal {
  /* Open state: fully visible and in position */
  opacity: 1;
  transform: scale(1) translateY(0);
}

@starting-style {
  dialog:modal {
    /* Entry animation starts from here */
    opacity: 0;
    transform: scale(0.95) translateY(16px);
  }
}

/* Animate the backdrop separately */
dialog::backdrop {
  background-color: rgba(0, 0, 0, 0);
  transition: background-color 0.25s ease, overlay 0.25s allow-discrete, display 0.25s allow-discrete;
}

dialog:modal::backdrop {
  background-color: rgba(0, 0, 0, 0.5);
}

@starting-style {
  dialog:modal::backdrop {
    background-color: rgba(0, 0, 0, 0);
  }
}

6. Animating newly inserted DOM elements

The second trigger for @starting-style is the first-time insertion of an element into the DOM. When JavaScript creates an element and appends it to the DOM, the @starting-style rule set applies for exactly the first render frame of that element. That makes @starting-style the ideal solution for list entry animations, toast notifications, dynamically loaded content, or skeleton-to-content transitions.

A practical use case: a list of cards loaded via an API. Each card should fade in as it is inserted into the DOM. With @starting-style, a plain CSS rule is enough, no JavaScript has to add a class after a timeout. The browser detects the insertion into the DOM, applies @starting-style, and starts the transition automatically. For staggered entry effects, you combine @starting-style with animation-delay and the :nth-child selector, or set the --delay custom property once via JavaScript at insertion time.

7. The ::backdrop and the overlay layer

Along with the introduction of the Popover API and the expansion of the <dialog> element, a new CSS concept was also introduced: the overlay layer. Elements in the overlay layer (popovers, modal dialogs) always render above the regular stacking context, regardless of z-index values in the normal DOM. The overlay property can be used in transition with allow-discrete to control when an element leaves the overlay layer.

In practice this means: when a dialog closes, it stays in the overlay layer until the exit transition has fully played out, otherwise it would disappear behind other elements before the animation finishes. The combination overlay 0.25s allow-discrete in the transition declaration ensures that the browser only removes the element from the overlay layer after the transition has completed. This detail is often the reason why @starting-style animations on dialogs and popovers do not work as expected without overlay allow-discrete.

8. @starting-style versus JavaScript approaches compared

Before @starting-style became available, there were various JavaScript-based approaches to achieve entry animations for elements coming from display: none. Choosing the right approach today is a trade-off between browser support requirements and code complexity.

Approach JavaScript required Exit animation Browser support 2026
@starting-style No Yes (allow-discrete) Chrome 117+, FF 129+, Safari 17.4+
requestAnimationFrame (double) Yes Manual via JS All browsers
CSS keyframes + JS class Yes (set class) Separate keyframes All browsers
visibility + opacity No Yes All browsers (no real display:none)
Web Animations API Yes (imperative) Yes All modern browsers

The decisive advantage of @starting-style over all JavaScript approaches is not just the smaller amount of code, but the tight coupling to the CSS transition system. Performance optimizations such as will-change: opacity, transform are automatically applied correctly to the animated properties. The animation runs on the compositor thread whenever possible, without any JavaScript involved that could block the main thread.

9. Browser support and progressive enhancement

@starting-style has been available since Chrome 117 (September 2023), Firefox 129 (August 2024) and Safari 17.4 (March 2024). As of 2026, global browser support sits above 90 percent. For the remaining browsers, mainly older mobile Safari versions and legacy Edge, @starting-style is a perfect case for progressive enhancement: without @starting-style, the element simply appears without animation, which is functionally correct. The core functionality (opening a popover, showing a dialog) works in every browser, only the animation is missing in old browsers.

For projects that explicitly need to support older browsers, an @supports query combined with a fallback is recommended. You declare the base functionality without animation, and add the entry animation with @starting-style inside an @supports (selector(:popover-open)) block. This approach is easier to maintain than a JavaScript polyfill that would have to replicate the timing behavior of @starting-style.


/* Progressive enhancement pattern for @starting-style */

/* Base styles, work in all browsers without animation */
[popover] {
  border: 1px solid #e2e8f0;
  border-radius: 12px;
  padding: 1rem;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
}

/* Enhanced animation, only when @starting-style is supported */
@supports (selector(:popover-open)) {
  [popover]:popover-open {
    opacity: 1;
    transform: translateY(0);
    transition:
      opacity 0.2s ease,
      transform 0.2s ease,
      display 0.2s allow-discrete,
      overlay 0.2s allow-discrete;
  }

  /* Exit state (popover not open) */
  [popover] {
    opacity: 0;
    transform: translateY(-8px);
  }

  @starting-style {
    [popover]:popover-open {
      /* Entry animation start point */
      opacity: 0;
      transform: translateY(-8px);
    }
  }
}

/* Staggered list items, entry animation with delay via custom property */
.card-list-item {
  opacity: 1;
  translate: 0;
  transition: opacity 0.3s ease, translate 0.3s ease;
  transition-delay: var(--stagger-delay, 0ms);

  @starting-style {
    opacity: 0;
    translate: 0 12px;
  }
}

Mironsoft

Modern CSS, frontend development and Hyva theme expertise

Ready to put modern CSS features to work?

We implement @starting-style, View Transitions and modern CSS animations in your Magento and Hyva projects, clean, accessible and with a correct progressive enhancement strategy.

CSS audit

Analysis of existing JS animations for CSS modernization potential

Implementation

Adding @starting-style, View Transitions and the Popover API into existing projects

Performance

Compositor thread animations, CLS optimization and accessibility compliant animating

10. Summary

CSS @starting-style closes one of the last major gaps in the native CSS animation system. The at-rule enables genuine entry animations for elements coming from display: none or newly inserted into the DOM, without JavaScript, without doubled requestAnimationFrame calls and without fragile timing dependencies. Combined with allow-discrete for discrete properties such as display and overlay, a complete bidirectional animation system emerges for popovers, dialogs, dropdowns and dynamically inserted content.

The progressive enhancement strategy is clear: @starting-style is available in every modern browser (Chrome 117+, Firefox 129+, Safari 17.4+) and covers over 90 percent of users in 2026. In unsupported browsers, the element simply appears without animation, which is functionally sound. Anyone still using JavaScript for entry animations on dialogs, popovers or dynamically inserted elements should seriously consider switching to @starting-style: less code, better performance, no race conditions.

CSS @starting-style: the essentials at a glance

Trigger

Applies to the first render frame: newly inserted DOM elements or a switch from display:none to visible.

allow-discrete

Keyword required in transition for discrete properties such as display and overlay.

Popover & dialog

:popover-open and :modal as the target state, @starting-style for the entry starting point. Animate the backdrop separately.

Browser support

Chrome 117+, Firefox 129+, Safari 17.4+. Progressive enhancement via @supports, a missing animation is not a functional bug.

11. FAQ: CSS @starting-style and entry animations

1What does CSS @starting-style do?
Defines the starting state of a transition for the first render frame, for newly inserted elements or a switch from display:none to visible. Enables entry animations without JavaScript.
2Why do I need allow-discrete?
Discrete properties such as display have no intermediate values. allow-discrete tells the browser to include this property in the transition anyway, with jump behavior at the right moment.
3Does @starting-style work with @keyframes?
No. @starting-style is exclusively for the transition system. For keyframe animations use animation-fill-mode: backwards and a from block in @keyframes.
4Exit animations when removing from the DOM?
@starting-style only covers entry. Exit animations on remove remain a JavaScript task, either a short delay before removal or the Web Animations API.
5@starting-style versus animation-fill-mode: backwards?
animation-fill-mode: backwards applies to @keyframes before the start. @starting-style defines the starting state for transitions on the very first render, a similar purpose but a different CSS system.
6Animate a popover without JavaScript?
Yes. A popovertarget attribute on the button, popover on the target, the browser opens it declaratively. @starting-style supplies the entry animation. No JS needed.
7Fallback for older browsers?
The element appears without animation, which is functionally correct. @supports (selector(:popover-open)) shields the animation styles from old browsers with no extra effort.
8Staggered list animations?
Combine transition-delay with a --stagger-delay custom property. @starting-style supplies the starting state, :nth-child or JS sets the delay values once.
9Why animate ::backdrop separately?
::backdrop is a standalone pseudo element with its own rendering context, its own @starting-style declarations are required for a synchronized entry animation.
10What is the overlay layer?
A rendering layer for popovers and dialogs above the normal stack. overlay allow-discrete in transition keeps the element in the overlay layer until the exit animation finishes.