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

/* 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

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.