01 article

Modals can animate from display none — here's the CSS rule

@starting-style plus the allow-discrete keyword finally let you transition from and into display:none in pure CSS, quietly killing the max-height hack and the offscreen-transform workarounds for modals, toasts, and popovers.

Modals can animate from display none — here's the CSS rule

Modals can animate from display none — here's the CSS rule

For the better part of a decade, animating an element that starts as display: none meant picking a workaround. You hid it with opacity and visibility and a transition delay so the fade finished before the box vanished. You animated max-height from zero to some made up number and watched the timing go soft. You pushed it offscreen with a transform and never let the layout actually drop out. Every one of those hacks traded a real constraint for a fake one, and every one of them leaked: a focusable hidden panel, a scrollbar that flickered, an exit that clipped because the node got removed a frame too early.

A two word at rule quietly landed in every current evergreen browser, and it does exactly what those hacks were approximating. It is called @starting-style, and the piece that matters is how it pairs with a keyword called allow-discrete.

What @starting-style does

@starting-style lets you name the value an element starts from the instant a transition kicks in on insert. Without it, a freshly added element has no before state to move from, so the browser just snaps it to its final styles. You declare the hidden values inside the block, and the browser uses them as the first frame of the transition, then animates to the element's normal values.

It only does something when there is a transition on the same element. No transition, no starting style, no motion. It is a from state supplier, not an animation engine. If you have seen the two frame dance of adding an element, forcing a reflow, and only then adding a class to trigger the entrance, this at rule is what makes that dance unnecessary.

The part that actually mattered: allow-discrete

Here is the bit that turned this from a nicety into a fix. display and overlay are discrete properties. They have no in between, so by default a transition on them just jumps. The allow-discrete keyword opts a discrete property into transitioning, which is what finally lets you fade an element in or out while it goes from display: none to display: block.

This is the whole unlock in a few lines:

.panel {
  display: none;
  opacity: 0;
  transition:
    opacity 0.25s ease,
    display 0.25s allow-discrete;
}

.panel.open {
  display: block;
  opacity: 1;
}

@starting-style {
  .panel.open {
    opacity: 0;
  }
}

Apply .open and opacity runs from zero to one while display flips from none to block. Remove .open and it reverses: the panel fades out and the box drops out of layout. No max-height, no visibility delay, no offscreen transform.

One caveat to sit with. A discrete property flips at the midpoint of its transition by default. So on an exit, the element stops being laid out halfway through the fade, which can look like it vanishes a beat early. If that bothers you, give display its own longer duration so the flip lands later, or keep the node in the DOM and only animate opacity and transform, then set display: none after the transition ends.

Entry, the easy half

Entry is the straightforward case. Add the element, or the class that reveals it, and it animates from the @starting-style frame to its resting state:

.toast {
  opacity: 0;
  transform: translateY(12px);
  transition: opacity 0.3s ease, transform 0.3s ease;
}

.toast.show {
  opacity: 1;
  transform: translateY(0);
}

@starting-style {
  .toast.show {
    opacity: 0;
    transform: translateY(12px);
  }
}

This is the pattern I reach for on toasts, drawers, and inline error messages. It replaces the requestAnimationFrame trick that used to be the only reliable way to make an entrance transition fire on a brand new node.

Gotchas I keep hitting

  • display and overlay need allow-discrete. Without it they snap, and you end up with a smooth opacity fade over an instant box appearance. That combination reads as a bug to anyone watching.
  • You cannot nest it for pseudo elements. An @starting-style block will not reach a ::backdrop through nesting, so a popover backdrop gets its own standalone block: @starting-style { .dialog[popover]::backdrop { background: transparent; } }.
  • It does not respect prefers-reduced-motion on its own. Gate the transition, or zero the durations under that media query, so motion sensitive users get a clean snap instead of a fade they did not ask for.
  • It is per element. It gives one element a from state. If you want a shared element morph between two different nodes, that is the View Transitions API, not this.
  • Removing the node too soon kills the exit. If you delete the element the moment you close it, the exit never plays. Wait for transitionend before removing, or leave the node with display: none and just toggle the class.

Support, and when to skip it

@starting-style is Baseline: Chrome and Edge 117, Safari 17.5, Firefox 129. That is every current evergreen browser, so you can ship it directly for a modern audience and only need a fallback if you are still defending older Firefox.

I skip it in two places. For a pure entry with no exit, a keyframe animation is shorter and clearer, and there is no reason to drag @starting-style in. And if I need to animate height to auto or measure a collapsed dimension, interpolate-size and the zero to one fraction grid trick still do that job better, because @starting-style is about a from state, not about making auto animatable.

For anything else that shows up and then goes away, this is now the first thing I try. The nice part is not the syntax. It is that the workarounds stop being necessary. You can finally write the transition you actually meant, and let the box do the disappearing.

Comments