Transition display: none Without Breaking Firefox

A CSS transition on display: none does nothing on its own. display is a discrete property, so it flips between values instead of interpolating, and transitions do not run on an element’s first style update anyway. Three pieces fix it: transition-behavior: allow-discrete, display in the transition list, and an @starting-style rule to transition from.

That much is well documented. What catches people out is that the three pieces have different support, and one of them is missing from Firefox entirely.

Why doesn’t a transition on display do anything?

Two separate rules are in the way.

The first is discreteness. MDN’s reference puts it plainly: “Discrete-animated properties generally flip between two values 50% through animating between the two.” Half an opacity value is a real opacity. Half of block is not, so the browser picks a side. Left alone, display snaps to none immediately and your fade never renders.

The second is that there is nothing to transition from. Per MDN’s transitions guide, “CSS transitions are not triggered on elements’ first style updates when they first appear in the DOM, which includes when display changes from none to another state”. An element coming back from display: none has no previous computed style the engine is willing to use, so even a correctly declared opacity transition starts and finishes on the same frame.

transition-behavior: allow-discrete answers the first. @starting-style answers the second. You need both.

What does the working recipe look like?

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

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

/* Must come after .panel.is-open. Same specificity, so source order decides. */
@starting-style {
  .panel.is-open {
    opacity: 0;
  }
}

Where display flips is the part worth understanding. It is not the 50% rule, because display: none is special-cased. Going from none to a visible value, MDN says “the value will flip to block at 0% of the animation duration so it is visible throughout”. Going the other way it “will flip to none at 100%”. So the element is painted for the whole duration in both directions, which is exactly what an entry and exit animation needs.

@starting-style only matters for transitions. If you drive the same effect with @keyframes, you do not need it.

Where does the @starting-style rule have to go?

After the rule it is starting from. MDN is explicit: “The @starting-style at-rule and the ‘original rule’ have the same specificity. To ensure that starting styles get applied, include the @starting-style at-rule after the ‘original rule’. If you specify the @starting-style at-rule before the ‘original rule’, the original styles will override the starting styles.”

Put it at the top of your stylesheet and it silently does nothing. There is no warning in devtools and no invalid property to spot, just an entry animation that refuses to play while the exit works fine. Check the source order before you check anything else.

Nesting sidesteps the problem, because a nested block is always after the declarations it sits inside:

.panel.is-open {
  display: block;
  opacity: 1;

  @starting-style {
    opacity: 0;
  }
}

One exception worth knowing: the nesting selector cannot represent a pseudo-element, so a ::backdrop starting style has to stay in a standalone @starting-style block.

Does a CSS transition on display: none work in Firefox?

The entry does. The exit does not, and the compatibility tables are easy to misread on this point.

Both ingredients are Baseline 2024, newly available since August 2024. @starting-style landed in Chrome 117, Firefox 129 and Safari 17.5. transition-behavior landed in Chrome 117, Firefox 129 and Safari 17.4. Read those two rows and you would conclude the technique is safe everywhere.

It is not, because supporting the property is not the same as transitioning display with it. MDN’s compatibility data tracks that separately, as “Transitions display property when set to allow-discrete”, and records Chrome 117 and Safari 18 against no support in Firefox. That separate row followed an issue filed against the compat data making this exact complaint, which was closed in March 2025.

The underlying problem sits in Mozilla bug 1882408, filed in February 2024 and still open as of September 2026. A Mozilla engineer’s comment on it traces the cause to animation of the display property having been disabled deliberately, which leaves transition-behavior nothing to act on. The consequence for your stylesheet is lopsided. An entry animation needs the visible display value from the very start, so it looks right in Firefox regardless; an exit animation needs display held back until the end, and there is nothing to hold it.

What about popovers and dialogs in the top layer?

They need a fourth piece, and it is the least supported of the lot. A popover or modal <dialog> leaves the top layer the moment it closes, which clips the exit animation even when display is handled. The fix is to list the overlay property in the transition so removal from the top layer is deferred until the animation finishes:

[popover] {
  opacity: 0;
  transition:
    opacity 300ms,
    overlay 300ms allow-discrete,
    display 300ms allow-discrete;
}

[popover]:popover-open {
  opacity: 1;
}

@starting-style {
  [popover]:popover-open {
    opacity: 0;
  }
}

overlay is Limited availability: Chromium only, with Mozilla’s implementation bug reopened and no Safari support. You never set it yourself either, because MDN notes it “can only be set by the browser”. You are allowed to name it in a transition and nothing else. Our comparison of the popover attribute and the dialog element goes further into which overlay primitive to reach for, and the same exit problem turns up when anchoring elements with CSS anchor positioning.

How do you animate the exit everywhere?

Transition visibility instead of display, and keep display out of it.

.panel {
  visibility: hidden;
  opacity: 0;
  transition: opacity 200ms ease, visibility 200ms;
}

.panel.is-open {
  visibility: visible;
  opacity: 1;
}

MDN documents a specific interpolation rule for visibility: values of the easing function “between 0 and 1” map to visible, and one of the start or end values must be visible for any interpolation to happen. That keeps the element on screen for the duration and hides it at the end, which is the behaviour allow-discrete was invented to give display. It also takes the element out of the accessibility tree and out of the tab order once hidden, so you are not leaving a focusable ghost behind.

The cost is layout. A visibility: hidden element still occupies its box, so this works for anything absolutely positioned or fixed, and badly for a panel in normal flow.

What I would do

Use allow-discrete and @starting-style for entry animations without hesitation. They work in all three engines, they degrade to an instant appearance, and the failure mode is invisible to a user.

Treat the exit as progressive enhancement. If a fade-out that plays in Chrome and snaps in Firefox is acceptable, ship the display version and move on. If it genuinely is not, use the visibility pattern for your absolutely positioned overlays. Either way, resist the transitionend listener and the class-toggling helper that usually follows it. That is a lot of code to maintain for an effect the platform will handle properly once bug 1882408 closes.

Whoooop builds front-end interfaces where this kind of detail decides whether an interaction feels finished or cheap. If you want overlays, menus and panels that behave correctly across engines instead of only in the browser they were built in, our responsive web design work is where that lives.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch