Using @starting-style for Animations on First Render

Anyone who's ever tried to animate an element that fades or slides into view the moment it appears has realised it's not as easy as you think it should be. Until recently, doing a nice “entry” animation required extra CSS/JS tricks – like adding special classes via JavaScript or using CSS keyframes. The good news: the CSS spec folks felt our pain and introduced the new @starting-style at-rule. This rule lets you define an element’s starting CSS for its debut appearance, making those initial mount animations much simpler.

Why was animating on first render hard? The core issue is that CSS transitions normally need a previous state to animate from. If an element is hidden (or not in the DOM) and then suddenly appears, there’s no prior rendered state – so by default, no transition runs. In fact, CSS transitions are not triggered on an element’s first appearance in the DOM (for example, when changing display: none to block). In the past, toggling an element from display: none to visible would just pop it in with no smooth transition. In frameworks like React, UI elements are frequently conditionally rendered—meaning they literally don't exist in the DOM until a certain state or prop changes. If your React component initially isn't rendered at all (for example, it's conditionally included only after a button click or state change), the browser had no previous style to animate from, leading to abrupt, jarring appearances.

The workaround typically involved pre-rendering elements invisibly (opacity: 0) or positioning them off-screen, ensuring they always existed in the DOM. Another common approach was toggling classes immediately after the first render to kick-start transitions, using JavaScript timing hacks like setTimeout.

These solutions worked, but were cumbersome. For example, consider a simple React component that should fade in on mount. Before @starting-style, you might do something like this:

import React, { useState, useEffect } from 'react';

function Badge({ count }) {
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    if (count > 0) {
      // Set visibility after mount to trigger the animation
      requestAnimationFrame(() => setIsVisible(true));
    } else {
      setIsVisible(false);
    }
  }, [count]);

  if (count === 0) return null;

  return (
    <div className={`badge ${isVisible ? 'pop' : ''}`}>
      {count}
    </div>
  );
}
React JSX
fade-in-box.jsx
.badge {
  transform: scale(0);
  transition: transform 0.3s ease;
  background: red;
  color: white;
  border-radius: 50%;
  padding: 6px 10px;
  display: inline-block;
}

.badge.pop {
  transform: scale(1);
}
CSS
styles.css

Initially, the badge renders without the .pop class, thus applying transform: scale(0) and hiding it. After the component mounts, useEffect triggers, adding the .pop class via requestAnimationFrame. With .pop applied, the badge transitions smoothly from transform: scale(0) to transform: scale(1), causing it to "pop" into view.This achieves the effect, but it required extra JS logic and a hidden class in CSS. We also had to ensure the element was initially in the DOM  to transition – if it started as display: none, the transition wouldn’t run at all.


The @starting-style CSS at-rule is a new feature (part of CSS Transitions Level 2) that essentially bakes those initial state values into CSS, no JavaScript needed. It allows you to define starting values for properties set on an element that you want to transition from when the element receives its first style update. In plain terms, this is like telling the browser: “When this element first appears, pretend it had these styles, and transition from there to its normal styles.”

So how do we use it? The syntax is straightforward. You can either write a standalone @starting-style block with selectors inside, or nest it inside an element’s CSS rule. Here’s a basic example using our badge "pop" scenario:

.badge {
  scale: 1; 
  transition: scale 0.3s ease;

  @starting-style {
    scale: 0;
  }
    
}
CSS

Now, whenever the Badge component is conditionally rendered (for instance, when a notification arrives and count goes from 0 to a positive number), it instantly pops into view, scaling up smoothly from scale: 0 to scale: 1. Notice that no special JavaScript class management or visibility toggling is necessary – the browser handles it. The @starting-style rule defines the styles before the first update, and the normal .badge rule provides the end state. As CSS-Tricks succinctly puts it, this at-rule lets us define styles for elements “just as they are first rendered in the DOM”. It’s perfect for smooth entrance animations for things like pop-ups, modals, tooltips, or any content that wasn’t on the page and then is added.

You can place @starting-style rules in two ways – nested inside the element’s rule (as we did above), or standalone at the top level with its own selector. Both are valid. If you use a separate block, just remember that the rules inside @starting-style have the same specificity as your normal CSS, so you typically want the @starting-style block to come after the regular rule in your stylesheet order. (When nested, this ordering is naturally satisfied as long as you write it after the final-state declarations.) This ensures the initial styles don’t get overridden before they can do their job.

Let’s go back to our React component example and see how it simplifies with @starting-style. We no longer need that extra state or effect to toggle a class:

function Badge({ count }) {
  return count > 0 ? (
    <div className="badge">{count}</div>
  ) : null;
}
React JSX
badge.jsx
.badge {
  scale: 1; 
  transition: scale 0.3s ease;

  @starting-style {
    scale: 0;
  }

  /* Additional styling for clarity */
  background: red;
  color: white;
  border-radius: 50%;
  padding: 6px 10px;
  display: inline-block;
}
CSS
styles.css

This is not only cleaner, but it sidesteps timing issues where you had to delay style changes. The browser will smoothly transition from the starting style to the final style as part of the element’s first render cycle.

As a side-note: you're probably used to the transform property, but modern CSS actually  lets us set transform-like properties individually. In fact, there are now separate CSS properties for translate, rotate, and scale – meaning you don’t always have to cram everything into a single transform shorthand. These individual transform properties are supported across all major browsers (Chrome, Firefox, Safari) as of the last couple years.

One of the big wins of @starting-style is handling elements that toggle from display: none. If you want to animate an element that was display-none (like a modal that’s initially not shown), you should know about the transition-behavior property. In CSS Transitions Level 2, they introduced transition-behavior: allow-discrete to enable transitions on discrete properties (properties that don’t interpolate, like display). To animate an element from display: none to display: block, you typically:

Include display in your transition list with the allow-discrete flag. For example: transition: opacity 0.5s, display 0.5s allow-discrete;.

  • Use @starting-style to define the starting state when the element is made visible. Crucially, that starting state should set the element to a visible display value (like display: block) along with whatever visual properties you want to animate. Setting display to the final value in the starting-style block ensures the element is rendered (but you can keep it hidden via opacity/scale until the transition runs).

For example, if a component’s CSS has display: none when “closed”, you might do:

.component.closed { display: none; opacity: 0; }

.component.open { 
  display: block; 
  opacity: 1; 
  transition: opacity 0.3s, display 0.3s allow-discrete;
  /* ... other final styles ... */
}

/* Starting style for entry: element is made display:block (so it can animate), but opacity starts at 0 */
@starting-style {
  .component.open {
    display: block;
    opacity: 0;
  }
}
CSS

In this setup, when you add the .open class (or attribute) to show the component, it will use the @starting-style values (display block + opacity 0) as the initial point and transition to display block + opacity 1. The result: a fade-in even though the element was previously display:none. This was basically impossible with pure CSS before!

The @starting-style rule is a welcome addition for frontend developers. It eliminates a lot of the boilerplate and timing hacks we used to need for simple entrance animations. By letting CSS define an element’s starting state on first render, it keeps our animation logic in CSS, where it arguably belongs, and reduces dependency on JavaScript for visual effects. And with good browser support across the board, you can now effortlessly create smooth entry animations for modals, popovers, tooltips, new list items – you name it – with a few lines of CSS.