CSS @starting-style Lets You Animate Elements on Entry Without JavaScript

The CSS @starting-style
at-rule sets the “before-open” state of an element. The browser then transitions from that state to the normal style the moment the element appears. Paired with transition-behavior: allow-discrete, it makes discrete props like display and visibility animatable in pure CSS. You can fade a <dialog> or a [popover] in and out without setTimeout(fn, 0), without double requestAnimationFrame, and without a JavaScript animation library. It ships stable in Chrome 117+, Edge 117+, Safari 17.4+, and Firefox 129+, so it is cross-browser Baseline.
The Problem @starting-style Solves
CSS transitions only fire when a value changes on an element that is already rendered. A brand-new element in the DOM has no “previous” state, so the browser jumps to the final value with no animation. The same wall appears when an element flips from display: none to display: block. The transition is cancelled the instant display changes.
For over a decade, front-end devs worked around this with JavaScript timing hacks:
- The
setTimeout(fn, 0)hack: insert the element with a “closed” class, wait one event-loop tick, then remove the class. Fragile, race-prone, and breaks when React or Vue batches state updates. - The double
requestAnimationFramepattern: more reliable thansetTimeout, but still imperative, still JavaScript, and still breaks with React Server Components and streaming HTML. - The
transitionendlistener: to fade an element before hiding it, you listen fortransitionend, then setdisplay: noneby hand. Missed or interrupted events leave elements stuck in ghost states.
Libraries like Framer Motion , React Transition Group , and Headless UI exist largely because of these two problems. A big chunk of their code just papers over the missing “animate on entry” and “animate before removal” hooks in CSS.
There is also an accessibility cost. JavaScript-driven enter animations often delay focus for modals, since the dev has to wait for the animation tick before calling .focus(). Screen readers can miss the opened dialog during that gap. When a dialog holds a form, that gap compounds the work of getting form errors to assistive tech on time
, since errors and focus moves both depend on timing.
Syntax and How @starting-style Works with Transitions
The rule comes in two forms. Nested inside a selector:
.card {
opacity: 1;
transition: opacity 300ms;
@starting-style {
opacity: 0;
}
}Or as a top-level block with its own selector:
@starting-style {
.card {
opacity: 0;
}
}
.card {
opacity: 1;
transition: opacity 300ms;
}When the browser first renders .card (either inserted into the DOM or coming back from display: none), it computes styles as if only the @starting-style rules apply. It then transitions to the normal style over the set transition-duration. The element has three states across its lifecycle:
| State | Source | When |
|---|---|---|
| Starting state | @starting-style declarations | First render / becoming visible |
| Active state | Normal CSS rules | After entry transition completes |
| Exit state | Removal or display: none rules | When hiding or removing |
Specificity is important here. @starting-style rules carry the same specificity as their selector. They get no special priority boost like @keyframes do. If a more-specific rule or an inline style overrides the same value, the starting value is silently ignored. So place @starting-style after the normal rules, or nest it inside the selector block to keep things tidy.
The Companion: transition-behavior: allow-discrete
Discrete props (display, visibility, content-visibility, and overlay) do not tween between values. Normally, the browser refuses to transition them at all. The transition-behavior: allow-discrete rule opts in to discrete transitions. The prop then flips its value at a set point in the transition (usually 50% for display). Without it, @starting-style alone will not animate elements that toggle display.
You can set it per-property in the shorthand:
transition: opacity 200ms, display 200ms allow-discrete;The overlay Property
Elements in the top layer (dialogs, popovers) have an overlay prop that says whether they stay in the top layer during transitions. If you do not list overlay in your transition with allow-discrete, the browser drops the element from the top layer at once on close, so your exit animation never plays. Always include it:
transition: opacity 200ms, display 200ms allow-discrete, overlay 200ms allow-discrete;Respecting prefers-reduced-motion
Wrap the transition rule in a prefers-reduced-motion media query, not the @starting-style block. The starting state still applies, so the element renders in its “before” state and then snaps to the final state. No visible motion plays:
.dialog {
@starting-style {
opacity: 0;
transform: scale(0.95);
}
}
@media (prefers-reduced-motion: no-preference) {
.dialog {
transition: opacity 200ms, transform 200ms,
display 200ms allow-discrete,
overlay 200ms allow-discrete;
}
}Animating a Native Dialog: Open and Close
Dialogs are the most common use case. Here is a working example that fades and scales a native <dialog> with no JavaScript timing code.
The HTML:
<dialog id="confirm">
<p>Delete this item?</p>
<form method="dialog">
<button>Cancel</button>
<button value="confirm">Delete</button>
</form>
</dialog>
<button onclick="document.getElementById('confirm').showModal()">
Open dialog
</button>The CSS:
dialog {
opacity: 0;
transform: scale(0.95);
transition: opacity 200ms ease,
transform 200ms ease,
display 200ms allow-discrete,
overlay 200ms allow-discrete;
}
dialog[open] {
opacity: 1;
transform: scale(1);
}
@starting-style {
dialog[open] {
opacity: 0;
transform: scale(0.95);
}
}
/* Backdrop fade */
dialog::backdrop {
background-color: rgb(0 0 0 / 0%);
transition: background-color 200ms,
display 200ms allow-discrete,
overlay 200ms allow-discrete;
}
dialog[open]::backdrop {
background-color: rgb(0 0 0 / 25%);
}
@starting-style {
dialog[open]::backdrop {
background-color: rgb(0 0 0 / 0%);
}
}Notice that the @starting-style block targets dialog[open], not bare dialog. The browser applies @starting-style only when the element transitions INTO the matching selector state. Targeting the wrong selector is the single most common mistake devs make with this feature.
Exit animations work too. When you call dialog.close() or the user presses Escape, the browser keeps the element in the top layer until the overlay transition ends. The dialog[open] selector stops matching, so the element transitions back to the base dialog rules (opacity 0, scale 0.95). No transitionend listener needed.
Focus management comes for free too. The native <dialog> element handles focus trap and focus restore by default. Pair that with @starting-style, and you get a fully accessible animated modal in under 20 lines of CSS and zero lines of animation JavaScript.
A practical note on the scale transition: any raster image inside the dialog will look soft for the few frames it sits above 100%. Vector artwork avoids that entirely, so if your icon or illustration only exists as a PNG, redraw it as scalable vector paths
and animate the crisp SVG instead.
Popovers with the Popover API
The Popover API
is the second canonical use case. Popovers flip between display: none and visible each time they open, so they cannot transition without allow-discrete and @starting-style.
The HTML needs no JavaScript wiring:
<button popovertarget="menu">Open menu</button>
<div id="menu" popover>
<ul>
<li>Settings</li>
<li>Profile</li>
<li>Log out</li>
</ul>
</div>A slide-down popover in CSS:
[popover] {
transform: translateY(-8px);
opacity: 0;
transition: transform 150ms ease,
opacity 150ms ease,
display 150ms allow-discrete,
overlay 150ms allow-discrete;
}
[popover]:popover-open {
transform: translateY(0);
opacity: 1;
}
@starting-style {
[popover]:popover-open {
transform: translateY(-8px);
opacity: 0;
}
}The :popover-open pseudo-class is the popover version of [open] on <dialog>. Light-dismiss (clicking outside a popover="auto" element) fires the close transition by itself. The browser waits for the transition to finish before pulling it from the top layer.
Watch out for nested popovers. If you animate transform on a popover, the element creates a new containing block. Any fixed-positioned children inside the popover end up placed relative to it, not the viewport. That can break dropdown submenus.
You can pair popover animations with a technique that tethers the popover to its trigger button (also Baseline in 2025) with no JavaScript at all.
@starting-style vs. View Transitions vs. Web Animations API
These three APIs solve different problems. Picking the wrong one means either over-building or missing features.
| Feature | Best for | Scope | Requires JS? |
|---|---|---|---|
@starting-style + transitions | Entry/exit animations for individual elements (dialogs, popovers, tooltips, conditional components) | Single element | No |
| View Transitions API | Page-level or route-level transitions, morphing one element into another across navigation | Entire view or named elements | Yes (minimal) |
| Web Animations API | Complex sequenced animations, pause/resume/reverse control, physics-based motion | Any element | Yes |
Use @starting-style when you have a binary show/hide state and want pure CSS. Reach for View Transitions when the change is bigger than one component: route changes, expanding a card into a detail view, or swapping between list and grid layouts. Use the Web Animations API when you need per-frame control, promise-based sequencing, or runtime knobs.
Framework Integration: React, Vue, and Svelte
@starting-style dramatically simplifies conditionally rendered components in front-end frameworks.
Before @starting-style, a React component that faded in on mount needed extra state, a useEffect, and a requestAnimationFrame call:
function Badge({ count }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (count > 0) {
requestAnimationFrame(() => setVisible(true));
}
}, [count]);
if (count === 0) return null;
return <div className={`badge ${visible ? 'pop' : ''}`}>{count}</div>;
}With @starting-style, the component becomes trivial:
function Badge({ count }) {
return count > 0 ? <div className="badge">{count}</div> : null;
}All animation logic lives in CSS:
.badge {
scale: 1;
transition: scale 300ms ease;
@starting-style {
scale: 0;
}
}The same pattern works in Vue (with v-if) and Svelte
(with {#if} blocks). When the framework inserts the element into the DOM, the browser picks up the @starting-style state and transitions from it on its own. No framework-specific wrapper needed. If you style with utilities, it is worth knowing what changed in Tailwind v4
first, since v4 exposes @starting-style as a built-in variant.
There is an SSR caveat worth knowing. @starting-style fires when an element first gets computed styles. For server-rendered HTML, that happens at hydration time on the client. If your page is server-rendered and the dialog starts in its [open] state in the HTML, users will see the entry animation play during hydration. To avoid this, add a class that suppresses the transition until the app is interactive.
If you are building reusable UI parts that need to work across React, Vue, and Svelte without framework-specific wrappers, the Web Components standard pairs well with @starting-style. The browser lifecycle callbacks line up with the DOM insertion model that triggers starting-style animations.
Fallbacks and Progressive Enhancement
@starting-style is Baseline since August 2024, but some users still run older browsers. Here is when each engine shipped support:
| Browser | Version | Release Date |
|---|---|---|
| Chrome | 117 | August 2023 |
| Edge | 117 | August 2023 |
| Safari | 17.4 | March 2024 |
| Firefox | 129 | August 2024 |
You can feature-detect it with @supports:
@supports at-rule(@starting-style) {
/* entry animation styles here */
}Graceful degradation is built in. Structure your CSS with the final state in the base rule and the initial state in @starting-style. Older browsers then render the element in its final state at once. Everything works, it just appears without animation.
Do not ship both a JavaScript timing-hack library and @starting-style for the same component. Pick one path per component based on your browser-support matrix. Keeping both paths doubles the bug surface for no gain.
One rough edge to know: Firefox had a bug with ::backdrop transitions that was fixed in version 131. If you support Firefox 129-130, test your modal backdrops across engines.
When can you drop the JavaScript fallback? Once your analytics show 97%+ of sessions on browsers that shipped after August 2024. For most consumer-facing sites, that line was crossed by early 2026.
Troubleshooting: Why Your Animation Is Not Running
Three mistakes drive most “it just appears with no animation” bug reports:
Missing
transition-behavior: allow-discrete. If the element togglesdisplay, you must listdisplayin the transition withallow-discrete. Without it, the display change is instant and cancels all other transitions.Wrong selector in
@starting-style. The starting-style block must target the state the element is moving INTO. For dialogs, usedialog[open], notdialog. For popovers, use[popover]:popover-open, not[popover].Specificity conflict. An ID selector, an inline style set by JavaScript, or an
!importantrule can override the@starting-stylevalues. Since@starting-styledoes not get a specificity boost (unlike@keyframes), a more-specific rule silently wins, and the starting value never applies. Use CSS custom props instead of inline styles when JavaScript needs to set animation values.
A fourth, less obvious issue: forgetting to list overlay in the transition for top-layer elements. The browser drops the element from the top layer at once if overlay is not being transitioned, so exit animations on dialogs and popovers get cut short.
Bonus: Animating to and from auto with interpolate-size
A related CSS prop, interpolate-size
, pairs well with @starting-style for disclosure widgets and accordions. Set interpolate-size: allow-keywords on the root element to allow transitions between a fixed length and intrinsic sizing keywords like auto, min-content, and max-content.
:root {
interpolate-size: allow-keywords;
}
.accordion-panel {
height: 0;
overflow: hidden;
transition: height 300ms ease;
}
.accordion-panel.open {
height: auto;
@starting-style {
height: 0;
}
}This kills the old trick of animating max-height to a huge value. That trick always had bad timing, since the duration applied to the full max-height range, not the real content height. interpolate-size works in Chrome 129+ and Edge 129+. Other browsers are still working on it.
Botmonster Tech