Using @starting-style for Modal Entry Effects

Part of CSS @starting-style & Entry Effects in Modern View Transitions & Scroll APIs


Browser rendering pipeline: @starting-style intercepts before first paint A horizontal flow diagram showing DOM Insertion, then @starting-style Apply, then Style Recalc, then Composite (opacity + transform), then First Paint. An arrow below labels the compositor-safe path. DOM Insertion @starting-style Apply initial state Style Recalculation Composite opacity + transform First Paint compositor thread — no layout cost without @starting-style: snap to final state at first paint (FOUC)

The symptom: modal flash and layout shift on open

When a modal is appended to the DOM, the browser resolves its styles synchronously before any transition can intercept. The result is a hard visual cut — the element appears instantly at full opacity and final dimensions, producing a flash-of-unstyled-content (FOUC) and a measurable cumulative layout shift (CLS) spike. No amount of JavaScript class toggling reliably prevents this because the first paint fires before the main thread can schedule a class application.

Root cause: DOM insertion bypasses initial state

The underlying cause is how browsers handle style resolution during synchronous DOM mutations. When appendChild or showModal() executes, the rendering engine queues an immediate style recalculation. Without an explicit starting state, the browser resolves the element against its final stylesheet values and commits them to the compositor before any transition is registered.

Because hardware-accelerated properties like opacity and transform live on the compositor thread, the compositor receives the final values immediately and has nothing to interpolate from — so it paints the end state on frame one. JavaScript-driven approaches that call void el.offsetHeight to force a reflow then set a class in requestAnimationFrame work around this, but they add a mandatory frame of latency and keep animation coordination on the main thread.

@starting-style solves this at the CSS level: it tells the browser what values to use before the first style resolution, so the compositor immediately has a start state and an end state and can begin interpolation without any JavaScript involvement.

Decision matrix: choosing the right entry approach

Scenario Recommended approach Why
Modern browsers only (Chromium 117+, Firefox 129+, Safari 17.5+) @starting-style + transition-behavior: allow-discrete Pure CSS, compositor-safe, zero JS
Mixed browser support required @starting-style + JS fallback via CSS.supports() Progressive enhancement
Scroll-triggered reveal @starting-style for mount, then animation-timeline for scroll motion Different pipeline stages
Page-level route change View Transitions API Snapshot-based, not entry-state-based
Container-size-dependent motion Container query motion trigger + @starting-style Size context needed first

Step-by-step resolution

  1. Verify the symptom in DevTools. Open the Performance panel, enable Paint Flashing, and record a modal open. You will see a full-element paint flash on frame one — confirming the transition was never registered.
  2. Declare the pre-transition state in @starting-style. Nest the modal selector inside @starting-style and set opacity: 0 and transform: scale(0.95). The browser will apply these values before the first style recalculation.
  3. Add the transition declaration to the live rule. Without an explicit transition on the element itself, the browser still snaps to the final state. Restrict animated properties to opacity and transform to stay on the compositor thread — avoid layout-triggering properties like width, height, or margin.
  4. Add transition-behavior: allow-discrete if you also need to animate display (e.g. transitioning away from display: none). This ships in the same browser versions as @starting-style.
  5. Write the JS fallback using CSS.supports('transition-behavior', 'allow-discrete') as the feature gate. For unsupported browsers, force a reflow then apply a class in requestAnimationFrame.
  6. Gate with prefers-reduced-motion. Wrap the transition in a media query and set transition: none for users who prefer reduced motion.

Production code pattern

/* 1. Active state: compositor-safe properties only.
      will-change promotes the layer before the transition fires. */
.modal {
  opacity: 1;
  transform: scale(1);
  will-change: opacity, transform;
  transition:
    opacity 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
    transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1),
    display 0.3s allow-discrete; /* requires transition-behavior: allow-discrete */
}

/* 2. Pre-transition state: registered before the first paint.
      The browser interpolates FROM here TO the .modal rule above. */
@starting-style {
  .modal {
    opacity: 0;
    transform: scale(0.95);
  }
}

/* 3. Fallback: browsers without @starting-style or transition-behavior.
      transition-behavior ships in every browser that also supports
      @starting-style — a reliable feature-detection proxy. */
@supports not (transition-behavior: allow-discrete) {
  .modal {
    opacity: 0;
    transform: scale(0.95);
    transition: none;
  }
  .modal.is-open {
    opacity: 1;
    transform: scale(1);
    transition:
      opacity 0.3s ease,
      transform 0.3s ease;
  }
}

/* 4. Accessibility gate: honour the user's motion preference.
      Overrides both the @starting-style path and the JS fallback path. */
@media (prefers-reduced-motion: reduce) {
  .modal,
  .modal.is-open {
    transition: none;
    opacity: 1;
    transform: scale(1);
  }
  @starting-style {
    .modal {
      opacity: 1;
      transform: scale(1);
    }
  }
}

Rendering Impact: opacity and transformcomposite. No layout or paint phases triggered during the transition.

/* JS layer: only active for the @supports fallback path.
   CSS.supports() cannot test @-rules directly; transition-behavior
   is the correct proxy — identical browser support matrix. */
const openModal = (el) => {
  if (!CSS.supports('transition-behavior', 'allow-discrete')) {
    el.style.display = 'block';
    /* Force a synchronous layout commit so the browser registers
       opacity:0 / transform:scale(0.95) as the starting state. */
    void el.offsetHeight;
    /* Defer class application to the compositor queue. */
    requestAnimationFrame(() => el.classList.add('is-open'));
  } else {
    /* Modern path: @starting-style handles initial state automatically. */
    el.showModal?.() ?? (el.style.display = 'block');
  }
};

/* Release the GPU layer after the transition completes.
   Leaving will-change active wastes VRAM on static elements. */
document.querySelectorAll('.modal').forEach(modal => {
  modal.addEventListener('transitionend', () => {
    if (!modal.classList.contains('is-open')) {
      modal.style.willChange = 'auto';
    }
  }, { once: true });
});

Rendering Impact: requestAnimationFrame defers to the compositor queue — composite. The void el.offsetHeight line triggers a one-time layout read; unavoidable in the fallback path.

Verification checklist

Constraints and trade-offs

  • Browser support floor: @starting-style requires Chromium 117+, Firefox 129+, Safari 17.5+. The JS fallback adds one frame of latency (~16 ms) on older engines — visible only at very short durations.
  • Transitions only, not keyframes: @starting-style governs CSS transitions exclusively. For keyframe-based entry animations use animation-fill-mode: backwards with an explicit from block instead.
  • display: none requires transition-behavior: allow-discrete: Without it, display switches are still discrete and the starting state is never reached. If you cannot use allow-discrete, keep the modal in the DOM at opacity: 0 rather than removing it from the flow.
  • will-change memory cost: Promoting both opacity and transform layers simultaneously doubles the texture allocation. Remove will-change via transitionend to reclaim VRAM — see layer promotion and will-change strategy for full cost accounting.
  • @supports cannot detect at-rules: Never write @supports (@starting-style) — it is invalid. Use transition-behavior: allow-discrete as the proxy in both CSS and JavaScript.
  • Stacking context side-effects: Adding will-change: transform creates a new stacking context, which can clip position: fixed descendants. Test z-index layering after enabling the promotion.

Frequently Asked Questions

Does @starting-style work with the View Transitions API?

Yes, but they operate at different pipeline stages. @starting-style handles element-level entry states before the first paint, while the View Transitions API manages cross-document or SPA route transitions via snapshotting. Use @starting-style for component-level modals and View Transitions for page-level navigation.

Can I use @starting-style with CSS scroll-driven animations?

@starting-style only applies to CSS transitions, not keyframe animations. If a modal is revealed via scroll, use @starting-style for the initial mount transition, then attach an animation-timeline for scroll-linked motion using the patterns described in scroll-driven animation patterns.

How does @starting-style affect CLS scores?

It reduces CLS by ensuring the element occupies its final layout dimensions from frame one. By interpolating opacity and transform — neither of which affects document flow — the browser avoids cumulative layout shifts entirely during the entry phase. See auditing layout shifts during CSS transitions for measurement techniques.

Why can’t I detect @starting-style with @supports?

@supports tests property–value pairs, not at-rules. transition-behavior: allow-discrete ships in exactly the same browser versions as @starting-style, making it a reliable feature-detection proxy in both CSS (@supports (transition-behavior: allow-discrete)) and JavaScript (CSS.supports('transition-behavior', 'allow-discrete')).