CSS @starting-style & Entry Effects

Part of Modern View Transitions & Scroll APIs.

@starting-style is a CSS at-rule that declares the computed values an element holds before it is first painted — giving the browser a defined starting point from which to interpolate a CSS transition on mount. It solves the long-standing problem of needing JavaScript to create declarative entry animations for newly inserted or newly visible DOM elements.


Concept definition

Before @starting-style, triggering an entry transition required a JavaScript round-trip: insert the element, read a layout property to force a style recalculation, then add a class with the resting state. That pattern is fragile, couples logic to rendering internals, and races with framework hydration.

@starting-style moves the starting-value declaration into the stylesheet. The browser evaluates the block during the style-calculation phase, before the first paint of the affected element. When the element then transitions to its resting state, the browser already holds both endpoints and can hand the interpolation to the compositor — no JavaScript, no layout flush, no race condition.

The rule is scoped per-element and integrates cleanly with the cascade. It has no effect on elements that are already painted; it fires once, on first render (or on transition from display: none to a visible display value when transition-behavior: allow-discrete is set).


Execution model

The diagram below shows where @starting-style fits in the browser’s rendering pipeline relative to a standard CSS transition:

@starting-style in the browser rendering pipeline Five pipeline stages: Style Calc, Layout, Paint, Composite, Display. @starting-style is evaluated at Style Calc; the compositor interpolates opacity and transform between Display frames. Style Calc Layout Paint Composite (GPU thread) Display @starting-style fires here opacity + transform interpolated off main thread each frame

Key points in the execution model:

  • @starting-style values are resolved at Style Calc before the first paint of the element — not on subsequent paints.
  • The browser then sets up the CSS transition from those starting values to the resting-state values declared in the normal rule.
  • When the transitioned properties are opacity and transform, the interpolation runs on the compositor thread, completely bypassing main-thread layout and paint on every intermediate frame.
  • display: none → block toggling is a discrete property change: by default, discrete properties snap instantly. Adding transition-behavior: allow-discrete opts the discrete change into the transition timeline, letting @starting-style fire on re-insertion.

Property / API reference table

Property / Rule Accepted values Compositing tier Notes
@starting-style { } Any CSS declaration block Depends on properties inside Evaluated once, before first paint
transition <property> <duration> <easing> <delay> Compositor if opacity/transform only Set on the resting-state rule, not inside @starting-style
transition-behavior normal | allow-discrete n/a (control flag) Required for display, visibility, content-visibility
transition-property opacity, transform, color, filter, … Composite for opacity/transform; Paint for others Avoid width, height, margin — they force layout
@property syntax, inherits, initial-value Paint Register custom properties to enable interpolation; without it, custom property transitions snap
animation-fill-mode: backwards none | backwards | forwards | both Compositor if animation uses opacity/transform Alternative for keyframe-based mount animations

Annotated code examples

1 — Modal entry: compositor-safe fade and scale

/* Intent: fade + scale a modal in on DOM insertion without JavaScript */
.modal {
  /* Resting state — where the element ends up */
  opacity: 1;
  transform: scale(1);

  /* allow-discrete: lets the display toggle participate in the transition */
  transition: opacity 250ms ease-out, transform 250ms ease-out;
  transition-behavior: allow-discrete;
}

/* Starting state — evaluated before first paint */
@starting-style {
  .modal {
    opacity: 0;
    transform: scale(0.96);
  }
}

/* Accessibility gate: respect vestibular sensitivity */
@media (prefers-reduced-motion: reduce) {
  .modal {
    transition: none;
  }
  @starting-style {
    .modal {
      opacity: 1;
      transform: scale(1);
    }
  }
}

Rendering Impact: compositeopacity and transform interpolate on the GPU compositor thread. No layout or paint invalidation occurs during the transition frames.


2 — Popover with display: none toggle

/* Intent: animate a native popover element on open/close */
[popover] {
  opacity: 1;
  translate: 0 0;
  transition: opacity 200ms ease, translate 200ms ease,
              display 200ms allow-discrete,
              overlay 200ms allow-discrete;

  /* overlay keeps the element in the top layer during exit transition */
  transition-behavior: allow-discrete;
}

@starting-style {
  [popover]:popover-open {
    opacity: 0;
    translate: 0 -8px;
  }
}

@media (prefers-reduced-motion: reduce) {
  [popover] {
    transition: none;
  }
}

Rendering Impact: compositeopacity and translate are compositor-promoted. The overlay and display transitions are discrete and resolved on the main thread in a single style recalculation at the boundary frames only, not during interpolation.


3 — Theme-aware entry with a registered custom property

/* Intent: interpolate a brand color on mount without snapping */
@property --entry-hue {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

.card {
  background: hsl(var(--entry-hue) 80% 55%);
  transition: --entry-hue 400ms ease-out, opacity 300ms ease-out;
  --entry-hue: 260; /* violet resting state */
  opacity: 1;
}

@starting-style {
  .card {
    --entry-hue: 30;  /* orange start */
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }
}

Rendering Impact: paintbackground (derived from --entry-hue) is a paint-tier property. It interpolates correctly because @property gives the browser the <number> syntax declaration needed to tween the value. Without @property, custom property transitions snap at the boundary.


DevTools workflow

Use Chrome DevTools to confirm @starting-style fires and the transition lands on the compositor:

  1. Open the Animations panel (More tools → Animations or Ctrl+Shift+P → "Show Animations"). Trigger the element’s mount. A transition record should appear; its duration should match your CSS transition-duration.
  2. Check the Layers panel (More tools → Layers). After the element mounts, select it in the layer tree. If it reads “Composited for: opacity” or “Composited for: transform” in the Details pane, the GPU is handling interpolation.
  3. Record a Performance trace. Trigger the mount while recording. Open the trace and search for “Composite Layers” tasks in the main-thread flame chart. You should see no Layout or Paint tasks during the transition duration — only Composite tasks on the compositor thread row.
  4. Inspect computed @starting-style values. In the Elements panel → Styles tab, tick “Show all” under the element. Immediately before mount (e.g. using a debugger statement in an onclick handler), the @starting-style block should appear in the computed styles with its initial values.
  5. Firefox DevTools. Open the Rules panel in the Inspector. @starting-style blocks appear as a separate rule origin. The Animation Inspector (Tools → Web Developer → Animation Inspector) shows the transition timeline and its progress.

Flag to enable in Chrome Canary for experimental popover transitions: chrome://flags/#enable-experimental-web-platform-features — required for overlay transition support below Chrome 125 Stable.


Failure modes & fixes

Problem Root cause Fix
Entry animation never fires on display: none toggle transition-behavior: normal (the default) treats display as an instant discrete change Add transition-behavior: allow-discrete to the resting-state rule
Animation fires correctly in Chrome but not Firefox 128 Firefox shipped @starting-style in 129; 128 ignores the rule Add a @keyframes fallback gated on @supports not (transition-behavior: allow-discrete)
Custom property color transition snaps instead of interpolates The browser has no type information for the custom property Register the property with @property, providing syntax and initial-value
React component mounts but transition does not fire React batches DOM mutations; the element is inserted and the resting class is applied in the same microtask, skipping the starting-style evaluation window Defer class/attribute addition to the next requestAnimationFrame, or use the CSS-only @starting-style approach without class toggling
SSR-hydrated component flashes unstyled before transitioning The server outputs the element with the resting state; @starting-style fires only on first client paint, but hydration may skip that cycle Wrap the component in a <Suspense> boundary or add a short visibility: hidden until hydration completes, then rely on @starting-style for the reveal

Accessibility and reduced-motion notes

@starting-style entry effects are motion by definition. Users who have prefers-reduced-motion: reduce set should receive static appearance — instant transition from invisible to visible is acceptable; sliding or scaling effects are not.

The correct pattern is to override transition: none inside the media query and reset the @starting-style values to match the resting state, so no brief invisible frame appears:

@media (prefers-reduced-motion: reduce) {
  .modal {
    transition: none;
  }
  @starting-style {
    .modal {
      /* Match resting state — element appears instantly at full opacity */
      opacity: 1;
      transform: scale(1);
    }
  }
}

For users who prefer prefers-reduced-motion: no-preference, you can safely use the full entry transition. Avoid using opacity: 0 in @starting-style without a transition, as this produces an invisible-but-interactive element until the next paint.

For dialogs and popovers specifically, ensure the element receives focus on open via autofocus or focus() — the CSS transition does not change focus behaviour, so keyboard users may miss the modal if focus is not managed.


Frequently asked questions

Does @starting-style work with display: none toggles?

Yes, but only when paired with transition-behavior: allow-discrete. Without it, the browser treats display as an instant discrete change and skips the transition entirely — the element simply appears.

How does @starting-style compare to JavaScript-based entry animations?

When restricted to opacity and transform, @starting-style transitions run entirely on the compositor thread. JS requestAnimationFrame loops must schedule work on the main thread every frame. The CSS approach eliminates that overhead and cannot be blocked by long tasks.

Can @starting-style trigger keyframe animations?

No. @starting-style governs CSS transitions only. For a keyframe-based entry animation, declare the initial state in the from block of @keyframes and set animation-fill-mode: backwards so the element holds the starting values before the animation begins.

What is the browser support status?

Chromium 117+, Firefox 129+, Safari 17.5+. For older browsers, use a @keyframes fade-in triggered by adding a class on DOM insertion, gated with @supports not (transition-behavior: allow-discrete).

Can I combine @starting-style with scroll-driven animation patterns?

@starting-style handles the mount transition; a animation-timeline: view() handles subsequent scroll-linked motion. They operate at different phases of an element’s lifecycle. Use @starting-style for the initial reveal, then attach the scroll timeline once the element is in the viewport.