Using @starting-style for Modal Entry Effects
Part of CSS @starting-style & Entry Effects in Modern View Transitions & Scroll APIs
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
- 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.
- Declare the pre-transition state in
@starting-style. Nest the modal selector inside@starting-styleand setopacity: 0andtransform: scale(0.95). The browser will apply these values before the first style recalculation. - Add the transition declaration to the live rule. Without an explicit
transitionon the element itself, the browser still snaps to the final state. Restrict animated properties toopacityandtransformto stay on the compositor thread — avoid layout-triggering properties likewidth,height, ormargin. - Add
transition-behavior: allow-discreteif you also need to animatedisplay(e.g. transitioning away fromdisplay: none). This ships in the same browser versions as@starting-style. - 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 inrequestAnimationFrame. - Gate with
prefers-reduced-motion. Wrap the transition in a media query and settransition: nonefor 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:
opacityandtransform— composite. 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:
requestAnimationFramedefers to the compositor queue — composite. Thevoid el.offsetHeightline triggers a one-time layout read; unavoidable in the fallback path.
Verification checklist
Constraints and trade-offs
- Browser support floor:
@starting-stylerequires 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-stylegoverns CSS transitions exclusively. For keyframe-based entry animations useanimation-fill-mode: backwardswith an explicitfromblock instead. display: nonerequirestransition-behavior: allow-discrete: Without it,displayswitches are still discrete and the starting state is never reached. If you cannot useallow-discrete, keep the modal in the DOM atopacity: 0rather than removing it from the flow.will-changememory cost: Promoting bothopacityandtransformlayers simultaneously doubles the texture allocation. Removewill-changeviatransitionendto reclaim VRAM — see layer promotion and will-change strategy for full cost accounting.@supportscannot detect at-rules: Never write@supports (@starting-style)— it is invalid. Usetransition-behavior: allow-discreteas the proxy in both CSS and JavaScript.- Stacking context side-effects: Adding
will-change: transformcreates a new stacking context, which can clipposition: fixeddescendants. Testz-indexlayering 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')).
Related
- CSS @starting-style & Entry Effects — the parent topic covering entry animation architecture
- Avoiding layout thrashing in CSS animations — why
opacityandtransformare the correct properties to animate - Implementing cross-document view transitions in SPAs — page-level transition counterpart to component-level entry effects