@starting-style vs Keyframes
Part of CSS @starting-style & Entry Effects in Modern View Transitions & Scroll APIs.
The problem
An entry animation runs once, as an element appears. CSS gives you two mechanisms for it: a transition seeded with @starting-style, or an @keyframes animation with animation-fill-mode. They look interchangeable for a simple fade, but they diverge sharply the moment the element is entering from display: none, a popover, or a <dialog> — cases where only the @starting-style + allow-discrete path works cleanly. Choosing wrong leaves you either fighting discrete-property boundaries or reaching for JavaScript you do not need.
Root-cause analysis: why entry from display: none is special
A transition animates between two states of an existing element. When an element is first rendered — or toggled from display: none to display: block — there is no prior state to transition from, so nothing animates. @starting-style solves exactly this: it defines the values the browser should treat as the before state the first time the element is styled, giving the transition a from-value.
display, overlay and other such properties are discrete: they flip instantly with no interpolable midpoint. To transition a popover or dialog in, the element must remain rendered long enough to animate, which requires transition-behavior: allow-discrete so the discrete flip is deferred to the end of the transition rather than applied immediately.
@keyframes sidesteps the from-state problem differently: an animation with animation-fill-mode: both applies its 0% frame as soon as it is assigned, so it needs no @starting-style. But a keyframe animation does not defer a discrete display flip on its own either — for popovers and dialogs you still combine it with allow-discrete on the display/overlay transition. Neither mechanism changes where interpolation runs: only the animated properties decide that, exactly as in scroll-driven animation patterns.
Decision matrix
| Situation | Use | Why |
|---|---|---|
| One-shot fade/slide from a defined start to a settled state | @starting-style + transition |
A transition is the simplest expression of a single A→B entry |
Entry from display: none → block |
@starting-style + transition + allow-discrete |
Supplies the from-state and defers the discrete display flip |
Popover / <dialog> open animation |
@starting-style + allow-discrete |
The top-layer element needs a from-state and deferred overlay/display |
| Entry needs intermediate stops or overshoot | @keyframes |
A transition has only two endpoints; keyframes have many |
| Entry should loop or pulse before settling | @keyframes |
Transitions cannot repeat; animation-iteration-count can |
| The from-state is the element’s natural default anyway | @keyframes with fill-mode: both |
No @starting-style needed; the 0% frame is applied on assignment |
Production code
The block animates a popover in with @starting-style and allow-discrete, then shows the @keyframes alternative for a multi-stop entrance — both compositor-safe and both gated for reduced motion.
/* Intent: fade-and-scale a popover in from display:none using @starting-style,
deferring the discrete display flip with allow-discrete. */
.popover {
opacity: 1;
transform: scale(1);
transition:
opacity 200ms ease,
transform 200ms ease,
display 200ms allow-discrete; /* defer the discrete flip to the end */
}
/* The from-state the browser uses the first time the popover is shown. */
@starting-style {
.popover:popover-open {
opacity: 0;
transform: scale(0.96);
}
}
/* Settled open state. */
.popover:popover-open {
opacity: 1;
transform: scale(1);
}
/* Alternative: a multi-stop entrance that a two-endpoint transition can't do. */
@keyframes pop-in {
0% { opacity: 0; transform: scale(0.9); }
70% { opacity: 1; transform: scale(1.02); } /* slight overshoot */
100% { opacity: 1; transform: scale(1); }
}
.toast {
animation: pop-in 260ms ease both;
}
/* Accessibility gate: no entry motion; show the settled state immediately. */
@media (prefers-reduced-motion: reduce) {
.popover,
.popover:popover-open,
.toast {
transition: none;
animation: none;
opacity: 1;
transform: none;
}
}
Rendering Impact:
opacity+transform— composite only.allow-discretegoverns thedisplayboundary at the transition’s edges, not per frame, so no per-frame layout is scheduled.
Verification checklist
Interruption behaviour: the deciding factor in practice
The decision table above is about what each mechanism can express. In production, the thing that usually decides is what happens when the entry is interrupted — and the two mechanisms behave completely differently.
A transition retargets. If a panel is a third of the way through opening when the user closes it, the closing transition starts from that exact position and travels back. Nothing snaps, and no state has to be tracked, because the current computed value is the start of the next interpolation. That is the correct behaviour for anything a user can toggle quickly, which covers most menus, popovers and disclosure widgets.
A keyframe animation restarts. Re-applying the rule begins at 0% again unless animation-composition is doing something clever, so a fast toggle produces a visible jump back to the start before the exit plays. For a one-shot entrance that nobody can interrupt — a page-load reveal, a toast that appears once — that is irrelevant. For an interactive control it is the difference between a component that feels responsive and one that feels like it is arguing with the user.
So the practical rule is narrower than the table suggests: if the element can change state faster than the animation runs, prefer the transition even when keyframes would express the shape more elegantly.
Constraints and trade-offs
@starting-styleonly supplies a from value; it cannot express intermediate stops — reach for@keyframesthe moment you need more than two endpoints.allow-discreteis required fordisplayandoverlay; forgetting it makes popovers and dialogs pop in with no animation.@keyframeswithfill-mode: bothholds the final frame, which can mask a missing settled state — verify the resting styles independently.- Browser support for
@starting-styleandallow-discreteis recent; feature-detect and let unsupported browsers show the element instantly. - Combining
@starting-styleentry with a view() timeline is possible but keep the two mechanisms on separate properties to avoid conflicting sources.
Related
- CSS @starting-style & Entry Effects — the parent guide to
@starting-style,allow-discreteand the top layer - Using @starting-style for Modal Entry Effects — the dialog-specific entry pattern
- Accessible Motion Architecture — reduced-motion strategy for entry and exit effects