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:
Key points in the execution model:
@starting-stylevalues 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
opacityandtransform, the interpolation runs on the compositor thread, completely bypassing main-thread layout and paint on every intermediate frame. display: none → blocktoggling is a discrete property change: by default, discrete properties snap instantly. Addingtransition-behavior: allow-discreteopts the discrete change into the transition timeline, letting@starting-stylefire 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: composite —
opacityandtransforminterpolate 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: composite —
opacityandtranslateare compositor-promoted. Theoverlayanddisplaytransitions 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: paint —
background(derived from--entry-hue) is a paint-tier property. It interpolates correctly because@propertygives 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:
- Open the Animations panel (
More tools → AnimationsorCtrl+Shift+P → "Show Animations"). Trigger the element’s mount. A transition record should appear; its duration should match your CSStransition-duration. - 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. - 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.
- Inspect computed @starting-style values. In the Elements panel → Styles tab, tick “Show all” under the element. Immediately before mount (e.g. using a
debuggerstatement in anonclickhandler), the@starting-styleblock should appear in the computed styles with its initial values. - Firefox DevTools. Open the Rules panel in the Inspector.
@starting-styleblocks 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.
Related
- Modal entry effects with @starting-style — production patterns for dialogs and popovers, including FOUC prevention during hydration
- View Transitions API Implementation — coordinate
@starting-styleentry effects with cross-document and same-document view transitions - Scroll-Driven Animation Patterns — complement mount transitions with scroll-linked motion using
animation-timeline - Hardware-Accelerated Properties — the complete list of compositor-safe properties and why layout-triggering properties break entry animation performance
- Container Query Motion Triggers — trigger entry effects based on container size rather than viewport size