@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
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