loke.dev
Header image for The Last JavaScript-Only Animation Hack Just Died (Meet CSS @starting-style)

The Last JavaScript-Only Animation Hack Just Died

Say goodbye to 'setTimeout' hacks and heavy animation libraries just to fade in an element; native CSS finally supports entry transitions from display: none.

· 4 min read

How many times have you reached for a 10ms setTimeout just to make a modal fade in?

If you’ve done any front-end work in the last decade, you know the drill. You want an element to transition from display: none to display: block. You change the class, but the transition doesn't fire because the browser can’t calculate a transition between "nothing" and "something" in the same frame. So, we resort to the "double-requestAnimationFrame" or the "lazy timeout" hack to force the browser's hand.

It felt dirty. It felt like we were lying to the browser just to get a simple opacity fade. But that era of hacky JavaScript-powered CSS triggers is finally, officially over.

The Problem: The "Flash of Instant Visibility"

CSS transitions require two states to interpolate between. When an element has display: none, it doesn't exist in the render tree. The moment you switch it to display: block, the browser says, "Cool, I'll draw this now." If you also tried to change the opacity from 0 to 1 in that same instant, the browser doesn't see a transition; it just sees the final state.

We used to solve it like this:

// The "I hate myself" pattern
el.style.display = 'block';
setTimeout(() => {
  el.style.opacity = '1';
}, 10);

Enter @starting-style

The new @starting-style rule is a way to tell the browser: "When this element first appears in the DOM (or changes from display: none), use *these* styles as the starting point for transitions."

It's effectively a "pre-render" state. Here is how you use it in the real world:

.modal {
  display: block;
  opacity: 1;
  transition: opacity 0.5s ease-in;

  /* The magic happens here */
  @starting-style {
    opacity: 0;
  }
}

Now, when that modal gets added to the DOM or has its display property changed, the browser looks at @starting-style, sees the opacity: 0, and says, "Oh, I should animate from 0 to 1." No JavaScript required. No timers. No jank.

Don't forget the exit (The "Discrete" Problem)

Fading *in* was only half the battle. Fading *out* was arguably worse. Usually, as soon as you set display: none, the element vanishes instantly, ignoring your 500ms transition.

To fix this, we need to pair @starting-style with a new value for the transition-behavior property: allow-discrete. This tells the browser to keep the element "alive" (in the render tree) until the transition finishes, even if the display property has technically changed to none.

Here is a complete, bulletproof example for a popover or modal:

.card {
  transition: 
    opacity 0.4s, 
    transform 0.4s, 
    display 0.4s;
  transition-behavior: allow-discrete; /* Essential for the exit! */
  
  opacity: 1;
  transform: scale(1);
  display: block;
}

/* The "Out" state */
.card.hidden {
  display: none;
  opacity: 0;
  transform: scale(0.95);
}

/* The "Initial Entry" state */
@starting-style {
  .card {
    opacity: 0;
    transform: scale(0.95);
  }
}

Why this actually matters

I know what you're thinking: "I already have a library for this."

Sure, Framer Motion or GSAP are great. But for 90% of UI interactions—toasts, dropdowns, mobile menus—importing a heavy animation library is overkill. Every kilobyte of JS we can replace with native CSS is a win for performance, especially on low-end devices where the main thread is already struggling.

Also, it plays incredibly well with the new native <dialog> element and the Popover API. Since those elements toggle between display: none and display: block (or overlay) natively, @starting-style is the only way to animate them without writing custom JS controllers.

The "Gotcha"

Browser support is the only catch. It’s currently live in Chrome, Edge, and Safari. Firefox support is right on the horizon (it's currently in Nightly).

If you need to support older browsers, you'll still need your JS fallbacks for a little while longer. But for internal tools or modern-leaning projects, you can finally delete those setTimeout hacks.

It’s a small addition to the CSS spec, but for those of us who have spent years fighting the display-none-to-block battle, it feels like a massive victory. Happy coding!