CSS transitions compare an old computed value with a new one. A freshly inserted element has no rendered old value. An element changing from display: none has the same problem: while hidden, it has no box to animate.
This is why adding a class and immediately inserting a notification often makes it appear at full opacity. The transition declaration is present, but there is no earlier rendered opacity for the browser to use.
Give the transition its missing state
@starting-style defines the values used before an element receives its first rendered style. Think of it as the frame immediately before the normal rule takes over. It only supplies the starting side of the transition.
.toast {
opacity: 1;
translate: 0;
transition: opacity 200ms, translate 200ms;
@starting-style {
opacity: 0;
translate: 0 0.5rem;
}
}
If JavaScript appends an element with the toast class, it can now fade and move into place. JavaScript still decides when the message exists. CSS owns the visual transition.
Order matters. In the non-nested form, place the @starting-style rule after the normal rule when both have equal specificity. Otherwise the normal rule can win in the cascade.
The nested and standalone forms do the same job
Nesting keeps the selector close to its open state. A standalone at-rule is useful when your project does not use native nesting yet.
.notice.is-visible {
opacity: 1;
}
@starting-style {
.notice.is-visible {
opacity: 0;
}
}
The starting rule does not run every time any class changes. It applies when the element gets its first style update, or when it becomes renderable after display: none. A normal visible-to-visible state change already has two computed values and does not need it.
Entry is easy; exit needs a discrete transition
display cannot interpolate through halfway values. It is a discrete property. By default, changing it to none removes the box before an opacity transition can be seen. transition-behavior: allow-discrete tells the browser to coordinate that switch with the other transitions.
.panel {
display: none;
opacity: 0;
translate: 0 0.5rem;
transition:
opacity 200ms,
translate 200ms,
display 200ms allow-discrete;
}
.panel.is-open {
display: block;
opacity: 1;
translate: 0;
@starting-style {
opacity: 0;
translate: 0 0.5rem;
}
}
When the panel closes, the browser keeps it displayed while opacity and translate finish, then switches to display: none. Without allow-discrete, the entry may work while the exit still disappears abruptly.
Dialogs and popovers add one more property
Dialogs and popovers enter the top layer. On exit, the browser also needs to delay their removal from that layer. Transition overlay alongside display. Authors cannot set overlay directly; it exists here so the browser can defer the top-layer change.
[popover] {
opacity: 0;
translate: 0 0.5rem;
transition:
opacity 180ms,
translate 180ms,
display 180ms allow-discrete,
overlay 180ms allow-discrete;
}
[popover]:popover-open {
opacity: 1;
translate: 0;
@starting-style {
opacity: 0;
translate: 0 0.5rem;
}
}
For a dialog, swap :popover-open for [open]. Keep the transition on the base rule so it exists during both opening and closing. Put the final open values on the open-state selector.
Treat the overlay transition as progressive enhancement and check its current browser support for your audience. Without it, the popover still opens; an unsupported browser may remove it from the top layer before the exit transition finishes.
Keyframe animations are a different mechanism. An entry @keyframes animation already declares its first frame, so it does not need @starting-style. This at-rule solves the missing before-value for transitions.
Keep the non-animated state useful
These features are progressive enhancement. Without support, the element should still open and close; it simply skips the transition. Do not make visibility depend on opacity alone.
@media (prefers-reduced-motion: reduce) {
.toast,
.panel,
[popover] {
transition-duration: 0.01ms;
translate: 0;
}
}
Test rapid open and close actions too. A slow exit can make an interface feel stuck, and a second click can reverse a running transition. Keep durations short and let the control state, not a timeout, decide whether the element is open.
