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

Two mechanisms for one entranceA transition needs a from-state supplied; a keyframe animation carries its own.Two mechanisms for one entrance@starting-style + transitionTwo endpoints, one interpolationReads the resting rule for the to-stateallow-discrete defers the display flipInterruptible — retargets from current valueBest for one-shot A to B entries@keyframes + fill-modeAny number of intermediate stops0% frame applies the moment the rule attachesCan loop, alternate or overshootRestarts rather than retargetsBest when the entry has stops
A transition needs a from-state supplied; a keyframe animation carries its own.

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

Picking the entry mechanismReach for keyframes only when the entrance needs more than two endpoints.Picking the entry mechanism@starting-style@keyframesOne-shot fade or slide toa settled stateSimplestWorksEntry from display: noneWith allow-discreteNeeds a class flipPopover or dialog openingHandles top layeroverlay not deferredIntermediate stops orovershootImpossibleCorrectPulse or loop beforesettlingImpossibleiteration-count
Reach for keyframes only when the entrance needs more than two endpoints.
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: noneblock @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.

Writing the entry once, for both mechanismsThe support query decides which rule survives, so both paths can coexist in one sheet.Writing the entry once, for both mechanisms1Write the resting appearance first — it is the to-state for both mechanisms.2Add @starting-style with the from-values and a transition on the resting rule.Modern engines animate the entrance3Guard a @keyframes fallback with @supports not (transition-behavior: allow-discrete).Older engines still see motion4Gate both behind prefers-reduced-motion and let the element appear at rest.One entry point for the accessibility gate
The support query decides which rule survives, so both paths can coexist in one sheet.
/* 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-discrete governs the display boundary 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-style only supplies a from value; it cannot express intermediate stops — reach for @keyframes the moment you need more than two endpoints.
  • allow-discrete is required for display and overlay; forgetting it makes popovers and dialogs pop in with no animation.
  • @keyframes with fill-mode: both holds the final frame, which can mask a missing settled state — verify the resting styles independently.
  • Browser support for @starting-style and allow-discrete is recent; feature-detect and let unsupported browsers show the element instantly.
  • Combining @starting-style entry with a view() timeline is possible but keep the two mechanisms on separate properties to avoid conflicting sources.