CSS entry transition not working from display:none: fix it with @starting-style
CSS · Intermediate · 6 min read · published
This article was written by Claude (Anthropic) and published automatically.
What this solves: Your popover or modal fades out fine but snaps in with no animation. Here's why transitions from display:none never run, and the CSS-only fix.
What Changed
If your CSS entry transition is not working from display: none, that is no longer a JavaScript problem. Two CSS features — the @starting-style rule and transition-behavior: allow-discrete — make show/hide animations work with pure CSS. They shipped in Chrome/Edge 117, Safari 17.4 and Firefox 129, so as of 2025 this is Baseline-available in every evergreen browser.
Before this, the browser had no before-change style for an element that was display:none (or not yet in the DOM), so there was nothing to transition from — the element simply popped into existence. And display itself is a discrete property, so on the way out it flipped to none immediately, cutting your fade-out short.
The Old Way vs The New Way
// Old: force a style recalc between "visible" and "animated"
function show(el) {
el.style.display = 'block';
// one rAF is famously flaky; two is the cargo cult
requestAnimationFrame(() => requestAnimationFrame(() => {
el.classList.add('is-open');
}));
}
function hide(el) {
el.classList.remove('is-open');
el.addEventListener('transitionend', function done(e) {
if (e.target !== el || e.propertyName !== 'opacity') return;
el.style.display = 'none'; // leaks if transitionend never fires
}, { once: true });
}
/* New: no JS timing at all */
.panel {
display: block;
opacity: 1;
scale: 1;
transition:
opacity 200ms ease,
scale 200ms ease,
display 200ms allow-discrete;
}
.panel[hidden],
.panel:not(.is-open) {
display: none;
opacity: 0;
scale: .95;
}
/* the missing "before" state for the entry transition */
@starting-style {
.panel.is-open { opacity: 0; scale: .95; }
}
For popover and <dialog>, add overlay too — that is the property controlling top-layer membership:
[popover]:popover-open { opacity: 1; }
[popover] {
opacity: 0;
transition: opacity .2s, display .2s allow-discrete, overlay .2s allow-discrete;
}
@starting-style { [popover]:popover-open { opacity: 0; } }
Why It Was Added
Every component library reimplemented the same fragile dance: set display, force a reflow or wait two animation frames, add a class, listen for transitionend, clean up. It broke in predictable ways — transitionend never fires if the tab is backgrounded or the transition is interrupted, so elements got stuck visible; double-rAF is not specified behaviour, just an observation about when style recalc happens; and interrupting a close animation with a reopen produced stuck half-states.
The top layer made it worse. A popover or modal <dialog> is yanked out of the top layer the instant it closes, so even a working opacity transition rendered behind other content or not at all. overlay as a transitionable discrete property is the only way to hold it there for the duration.
How It Works Underneath
A transition needs two computed styles: before-change and after-change. @starting-style is a separate cascade bucket the engine consults only when an element has no before-change style — first render, or coming back from display:none. Discrete properties normally flip at the 50% mark; allow-discrete keeps that rule, except that a transition to a visible value flips at 0% and to none flips at 100%, which is exactly what you want for enter/exit.
flowchart TD
A["class toggled: display none -> block"] --> B{Element had a<br/>before-change style?}
B -- yes --> C[Use previous computed style]
B -- "no (was display:none)" --> D["Look up @starting-style<br/>matching rules"]
D -- found --> E["before = opacity 0, scale .95"]
D -- none --> F["No transition: snap to final"]
C --> G[after-change style computed]
E --> G
G --> H{"display / overlay in<br/>transition-property with<br/>allow-discrete?"}
H -- yes --> I["display flips to block at 0%,<br/>to none at 100%"]
H -- no --> J["display flips instantly -><br/>exit animation invisible"]
I --> K[Interpolate continuous props<br/>over duration]
Two consequences fall straight out of the diagram. First, @starting-style does nothing on exit — there is always a before-change style when leaving. Second, if the entry selector doesn't match at the moment of insertion, branch F fires and you get the snap you were debugging.
Should You Adopt It Yet
Yes for enter/exit animations of popovers, dialogs, tooltips, dropdowns and toasts. Support is universal in evergreen browsers and the degradation is benign: an older engine ignores @starting-style and shows the element instantly, which is still a working UI.
Wait if you need interruptible, physics-y animations with spring easing, or you're animating layout between two rendered positions — that is still Web Animations API / view transitions territory. Also note entry transitions are not run for descendants of an element that is itself appearing, so a staggered list inside a modal that is fading in won't stagger; keep the container mounted and animate children individually.
Migration Notes
Grep for the tells of the old pattern and delete them one component at a time:
requestAnimationFrame(() => requestAnimationFrame(— the double-rAF hackoffsetHeight/offsetWidthreads used purely to force reflowtransitionendlisteners whose only job is settingdisplay = 'none'animationend+setTimeout(…, 300)pairs that duplicate a CSS duration
The usual failure after migrating: you moved the entry state into @starting-style but forgot display in the transition shorthand, so exit is instant. Check both directions. Second failure: writing @starting-style with a selector less specific than the open-state rule — the starting style is consulted as a normal cascade, so @starting-style { .panel { opacity: 0 } } loses to .panel.is-open { opacity: 1 } only if specificity permits. Mirror the open-state selector exactly inside @starting-style.
For React/Vue, stop unmounting on close and let CSS own the visibility — or keep your unmount but delay it by the same duration; @starting-style handles the enter, and the exit needs the node to still exist. Prefer the hidden attribute or a class over conditional rendering for anything you want to animate out.
Key takeaway: An entry transition needs a before-change style the browser can find: declare it in @starting-style, and add transition-behavior: allow-discrete so display and overlay animate instead of snapping.
Real-world challenge
A React team ships a toast component. In Chrome the toast slides in smoothly, but in a colleague's build it appears instantly with no animation. The CSS is identical. The difference: the working version renders the toast with `display:none` toggled by a class, the broken one mounts and unmounts the element from the DOM. Both use @starting-style. Diagnose it.
Diagnosis
@starting-style works for both cases in principle — first render into the DOM counts as an entry change. But when React mounts the node it is often inserted with the final class already applied, and in some setups the element is inserted and styled inside the same frame where layout is forced (e.g. a getBoundingClientRect() call in a positioning effect). Forcing style/layout can resolve the after-change style before the transition is set up, or the node is inserted already-visible in a container that was itself just revealed — nested entry transitions on an ancestor that is also entering are skipped.
Check order:
- In DevTools, does the element have the
@starting-stylerule shown in Computed → matched rules? If not, the selector doesn't match at insertion time (e.g. class is added one tick later). - Is an ancestor also transitioning from
display:nonein the same frame? Entry transitions don't run for descendants of an element that is itself appearing. - Is a layout-reading effect running synchronously on mount?
Fix — make the entry state unconditional on the component's own selector, and stop toggling an extra class in an effect:
.toast {
opacity: 1;
translate: 0 0;
transition: opacity .2s, translate .2s;
}
@starting-style {
.toast { opacity: 0; translate: 0 8px; }
}
Then mount the toast directly with its final class, and keep the parent container permanently in the DOM (never display:none on the wrapper) so the child's entry transition isn't suppressed.