A CSS class toggle takes an element from `display: none; opacity: 0` to `display: block; opacity: 1` with `transition: opacity 300ms`. Why is there no fade-in, why does the fade-out also fail, and which CSS fixes each?
answer
- two failures, one symptom
- no previous style to start from
- some properties have no midpoint
- one at-rule supplies the entry value
- one keyword defers the hide
basics
~20 sThere is no fade-in because an element leaving display: none is rendered for the first time and has no before-change value to transition from — @starting-style supplies one. The fade-out fails because display is a discrete property that flips immediately, hiding the element; transition-behavior: allow-discrete defers that flip to the end.
solid answer
~50 sTwo separate mechanisms are failing. On the way in, the element was not rendered at all, so at the moment the class lands there is no previous style for `opacity` to interpolate from and the browser applies the final value directly; `@starting-style` exists precisely to declare the style the element should transition *from* on first render. On the way out, `display` is a discrete property — it has no interpolable midpoint between `block` and `none` — so by default it is not transitioned and flips instantly, removing the box before `opacity` has moved. `transition-behavior: allow-discrete` lets discrete properties participate; discrete values normally swap at the halfway point, but `display` and `content-visibility` are special-cased so the element stays visible for the whole duration. The complete rule is `transition: opacity 300ms, display 300ms allow-discrete;` plus an `@starting-style` block giving the opening state `opacity: 0`. Both features shipped across the major engines during 2023–2024.
code
css · 15 lines.panel {
display: none;
opacity: 0;
transition: opacity 300ms ease, display 300ms allow-discrete;
}
.panel.open {
display: block;
opacity: 1;
}
/* the value the element transitions FROM on its first render */
@starting-style {
.panel.open { opacity: 1; opacity: 0; }
}go deeper
Recognise that toggling display cancels a fade, and know the older habit of animating opacity on an element that stays in the layout instead.
Explain the two mechanisms separately: a discrete property has no interpolable midpoint, and a newly rendered element has no before-change style — then name the CSS feature that addresses each.
Assemble the whole working pattern, get the durations right so the box outlives the fade, and describe how it degrades where the features are unsupported.
Decide whether enter/exit motion is expressed in CSS at all versus driven by the application layer, and set the standard so every disclosure component in the product behaves and degrades the same way.
## Two different failures wearing one costume "My element doesn't fade" hides two distinct causes, and a strong answer separates them before proposing a fix. ## Failure one: nothing to transition *from* A transition needs a before-change value and an after-change value. An element with `display: none` generates no box; when the class toggle makes it `display: block` it is being rendered for the first time in this cycle, and the specification says transitions do not start on the first style update in which an element becomes rendered. The browser has only the after value — `opacity: 1` — so it paints that immediately. Historically people forced the issue from script by reading a layout property to flush styles between the two changes; the CSS answer is `@starting-style`: ```css @starting-style { .panel.open { opacity: 0; } } ``` The rule inside `@starting-style` supplies the value the element should be treated as having *before* its first rendered style, which gives the transition its missing endpoint. It applies only in that first update and is ignored afterwards, so it does not interfere with later toggles. ## Failure two: `display` is discrete Properties have an animation type. `opacity` interpolates numerically; `display` is **discrete** — there is no meaningful value halfway between `none` and `block`. By default discrete properties are not transitioned at all: the new value applies at once. So on close, `display: none` takes effect in the same frame the class changes, the box disappears, and the 300ms opacity transition either never runs or runs on an element nobody can see. `transition-behavior: allow-discrete` opts a transition into carrying discrete properties. The general rule for discrete values is that they swap at the 50% mark of the duration, but `display` and `content-visibility` are deliberately special-cased: the element takes whichever value keeps it **visible for the whole duration**. Going to `none`, the flip is deferred to the end; coming from `none`, it happens at the start. That is exactly the behaviour an enter/exit animation needs. The keyword can be written as its own longhand or inline in the shorthand item: ```css .panel { opacity: 0; display: none; transition: opacity 300ms, display 300ms allow-discrete; } .panel.open { opacity: 1; display: block; } @starting-style { .panel.open { opacity: 0; } } ``` With all three pieces present, opening fades in from the starting style while `display` flips to `block` immediately, and closing fades out while `display` waits until the transition ends before hiding the box. ## Getting the durations right The `display` item must last at least as long as the opacity item; if it is shorter, the box is hidden while the fade is still running. Matching the two durations is the simple and safe choice. ## Elements in the top layer The same pattern extends to elements that are promoted out of normal flow when shown, which also need the `overlay` property transitioned with `allow-discrete` so they are not yanked out of that layer at the start of the exit animation. The mechanics are identical: a discrete property, opted in, special-cased to keep the element visible. ## Browser support and degradation `transition-behavior` and `@starting-style` both shipped across Chromium, Safari, and Firefox over 2023–2024, so they are broadly usable as of 2026 — but the degradation story is worth stating. In an engine without them, the unknown declarations are simply dropped: the element still shows and hides correctly, it just does so instantly. That makes the whole pattern safe as progressive enhancement, which is a large part of why it was designed this way rather than as a new API. ## The general principle to carry away Before debugging a transition, ask two questions. First: does this property have an interpolable animation type, or is it discrete? Second: did the element have a rendered before-change style at all? Almost every "why won't it animate" bug in CSS resolves to one of those two, and the fixes — `allow-discrete` and `@starting-style` — map one to one onto them.
- At what point in the duration does a discrete property change value once `allow-discrete` is set?Normally at the 50% mark — the old value holds for the first half, the new one for the second. `display` and `content-visibility` are special-cased: they take whichever value keeps the element rendered for the entire duration, so `none` is deferred to the end and applied at the start when coming from `none`. That special case is what makes exit animations possible.
- What happens if the `display` item's duration is shorter than the `opacity` item's?The box is hidden before the fade finishes, so the exit animation is visibly truncated — the element vanishes partway through. Since `display` is deferred to the end of *its own* transition, its duration must be at least as long as the longest visible property. Matching durations is the reliable default.
- How does this pattern behave in a browser that supports neither feature?Both declarations are unknown and dropped at parse time, so `display` flips instantly and no starting style is supplied: the element appears and disappears with no animation. Functionality is intact, only the motion is lost, which makes the pattern safe to ship without feature queries.
saying these in an interview costs you the question
- Says opacity is not animatable when display changes
- Uses visibility and calls it equivalent to the discrete display fix
- Believes @starting-style applies on every toggle, not the first render
- Sets allow-discrete but omits a starting style and expects a fade-in
- Gives display a shorter duration than the fading property