Home / Articles / Animation & Motion /
Transitioning to/from display: none with @starting-style
The CSS holy grail: animating elements into and out of existence with display: none — zero JavaScript required.
Why display: none broke animations
For years, developers faced an impossible choice: use display: none to properly hide elements (removing them from layout and accessibility trees) or animate with opacity and visibility (which leave invisible elements still taking up space and in the tab order). The core problem: display is a discrete property with no intermediate states. Transition from display: block to display: none? Instant change, no animation.
The common workaround used JavaScript to stagger class changes: remove display: none, wait a frame, then add a class that triggers the fade-in. Clunky and error-prone. Modern CSS solves this with @starting-style and transition-behavior: allow-discrete.
Entry animations with @starting-style
@starting-style defines the initial state an element has on its first frame of visibility. The browser then transitions from that state to the element's computed style:
.modal {
opacity: 1;
transform: scale(1);
display: block;
transition: opacity 0.3s ease, transform 0.3s ease;
@starting-style {
opacity: 0;
transform: scale(0.95);
}
}
.modal.is-hidden {
display: none;
opacity: 0;
transform: scale(0.95);
}
When the element becomes visible (by removing is-hidden), the browser creates an entry animation from the @starting-style values to the final state. When hidden again, it transitions from final to the is-hidden state.
One important note: @starting-style fires on the first style computation for that element — meaning it also fires when a newly inserted DOM node first renders, not just when a class is toggled. This makes it equally useful for elements added dynamically via JavaScript.
display: block (or another non-none value) in its base state for @starting-style to work on entry.Exit animations with transition-behavior
Entry animations are only half the story. To animate elements out to display: none, add transition-behavior: allow-discrete to the transition list:
.dialog {
opacity: 1;
translate: 0 0;
display: block;
transition:
opacity 0.25s ease,
translate 0.25s ease,
display 0.25s allow-discrete;
@starting-style {
opacity: 0;
translate: 0 1rem;
}
}
.dialog[hidden] {
opacity: 0;
translate: 0 -1rem;
display: none;
}
Without allow-discrete, the browser sets display: none instantly when the hidden attribute is added, cutting the fade/slide animation short. With it, the browser holds the element visible, runs the opacity and translate transitions, then applies display: none after they complete.
Complete entry-and-exit pattern
Combine both techniques for full entry-and-exit animations. This is the canonical pattern for dialogs, toasts, tooltips, and dropdowns:
.toast {
opacity: 1;
translate: 0 0;
display: flex;
transition:
opacity 0.3s ease,
translate 0.3s ease,
display 0.3s allow-discrete;
@starting-style {
opacity: 0;
translate: 0 1rem;
}
}
.toast.is-closed {
opacity: 0;
translate: 0 -1rem;
display: none;
}
/* Dark mode override */
@media (prefers-color-scheme: dark) {
.toast {
background: oklch(18% 0.02 265);
border: 1px solid oklch(35% 0.03 265);
}
}
The same CSS handles both directions: entry (fade-in, slide up) and exit (fade-out, slide down). No JavaScript, no duplicate classes, no timing tricks.
Notice the asymmetric motion: the toast enters sliding up from below but exits sliding down from above. This intentional direction asymmetry feels more natural — it mimics how notifications typically behave in native interfaces, and gives a visual sense of the element arriving and departing from different paths.
Top layer elements and the overlay property
Dialogs and popovers live in the top layer, a special stacking context that always sits above regular content. When hiding these elements, the overlay property must also be in the transition list:
[popover] {
opacity: 1;
scale: 1;
transition:
opacity 0.2s ease,
scale 0.2s ease,
display 0.2s allow-discrete,
overlay 0.2s allow-discrete;
@starting-style {
opacity: 0;
scale: 0.97;
}
}
[popover]:not(:popover-open) {
opacity: 0;
scale: 0.97;
}
Without overlay in the transition, the popover drops out of the top layer the instant it's hidden, making the exit animation invisible. This is a common gotcha when animating <dialog> or [popover] elements.
Browser support and usage in 2026
@starting-style and transition-behavior: allow-discrete are both supported in Chrome 117+, Safari 17.5+, and Firefox 129+. Combined, they're available in roughly 90% of global browsers as of 2026. The fallback for older browsers is graceful: elements still show and hide, they just don't animate. For critical UI like dialogs, consider testing that the non-animated behavior remains usable.