CSS @starting-style lets you animate elements on entry without JavaScript

This post is open for corrections. Highlight any line of text to challenge it or ask for a clearer explanation and leave a comment.
The CSS @starting-style at-rule sets the “before” state of an element. When the element appears, the browser transitions from that state to its normal style. Add transition-behavior: allow-discrete and even display can animate. You can fade in a <dialog>, a [popover] or a new list item with no setTimeout, no double requestAnimationFrame and no animation library. The entry works in every major engine. The exit is a different story, and the browser table below shows why.
Key Takeaways
@starting-stylegives a new or unhidden element a style to transition from.- Entry animations work in Chrome, Edge, Firefox and Safari today.
- Exit fades on dialogs and popovers fully work only in Chromium.
@supports at-rule()misses most browsers, so detect support in JavaScript.- Elements removed from the DOM still need a small JS step to animate out.
What CSS @starting-style animates in each browser
Most guides stop at “Chrome 117, Firefox 129, Safari 17.5”. That number only covers the entry. A full open and close animation leans on four more features, and they shipped on very different schedules. These are the first versions with support, from MDN’s browser compat data as of October 2026:
| Feature | Chromium | Firefox | Safari |
|---|---|---|---|
@starting-style | 117 | 129 | 17.5 |
allow-discrete | 117 | 129 | 17.4 |
display exit | 117 | 18 | |
overlay | 117 | ||
::details-content | 131 | 143 | 18.4 |
interpolate-size | 129 | ||
sibling-index() | 138 | 154 | 26.2 |
@supports at-rule() | 148 | 157 |
The entry animation is Baseline, but the exit is not. Firefox accepts allow-discrete but does not yet transition display itself (bug 1882408), so a closing dialog skips its exit transition and vanishes at once. Safari holds display from version 18, but it has no overlay property. Without overlay, the element leaves the top layer the moment it closes, so test the close there. Chrome and Edge run every example and demo on this page from start to finish.
That split is fine for most UI. The element still opens with motion everywhere and closes cleanly, just without a fade in two engines. However, if your design depends on the exit, check it in Firefox and Safari before you ship.
The problem @starting-style solves
A CSS transition only fires when a property value changes on an element that is already rendered. A brand-new DOM element has no “previous” style, so the browser paints its final value at once. The same thing happens when an element goes from display: none to display: block. The transition is skipped the instant display changes.
For years, front-end devs patched this with JavaScript timing tricks. One was setTimeout(fn, 0): insert the element with a “closed” class, wait one tick, then drop the class. Another was the double requestAnimationFrame. A third was a transitionend listener that set display: none by hand after a fade. All three break in odd ways, for example when React batches state updates or an event never fires.
Libraries like Framer Motion, React Transition Group and Headless UI carry a lot of code for these two gaps. There is an accessibility cost too. JS-driven modals often wait for the animation tick before calling .focus(), and screen readers can miss the dialog in that gap. When the dialog holds a form, the delay also gets in the way of announcing form errors on time.
Basic usage: @starting-style syntax and entry transitions
The rule comes in two forms. You can nest it inside a selector:
.card {
opacity: 1;
transition: opacity 300ms;
@starting-style {
opacity: 0;
}
}Or you can write it as a top-level block with its own selector:
@starting-style {
.card {
opacity: 0;
}
}
.card {
opacity: 1;
transition: opacity 300ms;
}In this example, when .card first renders, the browser computes each property as if only the @starting-style rules applied. Then it transitions to the normal style, like any other style change. That first render can be a DOM insert, a return from display: none, or the first page load. An element has three states across its life:
| State | Source | When |
|---|---|---|
| Starting | @starting-style | First render |
| Active | Normal rules | After entry |
| Exit | Base or hidden rules | On close |
A @starting-style rule has the same specificity as its selector and gets no boost. Keyframe animations are different, since they sit in their own cascade layer above normal rules. So if an ID selector or an inline style sets the same property, the starting value is silently lost. Josh Comeau walks through this problem, and it is the most common reason the rule “does nothing”. Place the block after the normal rules, or nest it, and keep both at the same specificity.
Starting style and display none: transition-behavior allow-discrete
Discrete properties like display, visibility, content-visibility and overlay do not tween. By default, the browser will not transition them at all. The transition-behavior: allow-discrete value opts them in. The value then flips at a set point during the transition. Without it, @starting-style alone cannot animate an element that toggles display.
You can set it per property in the shorthand:
transition: opacity 200ms, display 200ms allow-discrete;The overlay property
Dialogs and popovers live in the browser’s top layer. The overlay property controls whether an element stays there during a transition. If you leave it out, Chromium drops the element from the top layer at once on close, and the exit never plays. Add it to every top-layer transition:
transition: opacity 200ms, display 200ms allow-discrete, overlay 200ms allow-discrete;Respecting prefers-reduced-motion
Wrap the transition in a prefers-reduced-motion query and leave @starting-style outside it. The starting state still applies, then snaps to the final state with no visible motion:
.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;
}
}Example: animating a native dialog open and close
Dialogs are the most common use case. This example uses one transition to fade and scale 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%);
}
}allow-discrete, so the browser yanks it the instant you close it.Notice that the @starting-style block targets dialog[open], not bare dialog. The browser applies the starting style only when the element moves INTO the matching state. Targeting the wrong selector is the most common mistake with this feature.
The close needs no extra code either. When you call dialog.close() or the user presses Escape, dialog[open] stops matching, and that state change starts the exit. The element then transitions back to the base dialog rules, and overlay keeps it in the top layer until the fade ends. You never write a transitionend listener.
Focus management comes for free as well. The native <dialog> traps focus and restores it on close. Pair that with @starting-style, and you get an accessible animated modal in under 20 lines of CSS.
One note on the scale effect: a raster image inside the dialog looks soft while it scales. Vector art stays sharp, so if your icon only exists as a PNG, redraw it as scalable vector paths first.
Example: popover transitions with the Popover API
The Popover API is the second big use case. A popover changes between display: none and visible each time it opens, so it needs both 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>This example builds 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;
}
}popovertarget opens it, a click outside closes it, and CSS runs both transitions. The menu is tethered to the button with anchor positioning.The :popover-open pseudo-class is the popover version of [open]. Light dismiss, a click outside a popover="auto" element, starts the close transition by itself. The demo also uses anchor positioning to pin the menu to its button.
Nested popovers can break. Animating the transform property gives the popover a new containing block, so any fixed-position children are placed relative to it, not the viewport.
CSS ending style: exit animations and removed elements
People often search for a CSS “ending style”. No @ending-style rule exists, and you do not need one. The exit state is simply the base rule the element falls back to after the state changes and [open] or :popover-open stops matching. Put the hidden look there, add allow-discrete, and the browser plays the exit.
There is one gap. When JavaScript calls el.remove(), the element leaves the DOM at once, and no CSS can animate a node that no longer exists. Here is an example helper that waits for the exit transition first:
.toast.leaving {
opacity: 0;
translate: 0 8px;
}async function removeWithExit(el) {
el.classList.add('leaving');
await Promise.allSettled(el.getAnimations().map(a => a.finished));
el.remove();
}The getAnimations() call returns the CSS transitions the class just started. Their finished promises resolve when the fade ends, and allSettled also covers a cancelled one. This is the one place where @starting-style patterns still need JavaScript. Frameworks hit the same wall when they unmount a component.
Example: staggered list entries
New list items added to the DOM can enter one after another. Give each item a transition-delay property based on its position, as in this example:
li {
opacity: 1;
translate: 0;
transition: opacity 300ms, translate 300ms;
transition-delay: calc(var(--i, 0) * 40ms);
transition-delay: calc(min(sibling-index(), 10) * 40ms);
@starting-style {
opacity: 0;
translate: 0 8px;
}
}The sibling-index() function returns the item’s position among its siblings. Browsers without it drop that second line, and fall back to a --i value you set in the markup, such as style="--i: 3". The min() cap keeps the 50th item from waiting two seconds.
@starting-style vs. @keyframes, View Transitions and the Web Animations API
These tools solve different problems. Picking the wrong one means either over-building or missing features.
| Tool | Best for | Scope | Needs JS |
|---|---|---|---|
@starting-style | Show and hide | One element | |
@keyframes | One-shot effects | One element | |
| View Transitions | Page changes | Whole view | ✅ |
| Web Animations API | Sequenced motion | Any element | ✅ |
A @keyframes entry can do most of what a starting style does, and it avoids the specificity conflict. Transitions win when the state can change back mid-way. Close a popover halfway through its entry, and the transition runs back from where it is. Two keyframe animations would jump instead.
So use @starting-style for a binary show and hide state with no script. Use View Transitions when the change is bigger than one component, like a route change or a card that expands into a detail view. Reach for the Web Animations API when you need per-frame control or promise-based chains.
Framework integration: React, Vue, Svelte and Tailwind
@starting-style makes conditional components much simpler. Take a React badge as an example. Before @starting-style, a badge 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 is trivial:
function Badge({ count }) {
return count > 0 ? <div className="badge">{count}</div> : null;
}All the transition logic lives in CSS:
.badge {
scale: 1;
transition: scale 300ms ease;
@starting-style {
scale: 0;
}
}The same pattern works with Vue’s v-if and Svelte {#if} blocks. When a state change makes the framework insert the element into the DOM, the browser picks up the starting state on its own. Svelte 5’s runes leave {#if} alone, so the CSS keeps working.
If you style with utilities, Tailwind v4 ships a starting: variant that compiles to the same at-rule. It is one of the Tailwind v4 changes worth knowing. Here is an example:
<div popover class="opacity-100 transition-opacity duration-300 starting:opacity-0">Starting styles also apply on the first page render, so with server-side rendering, if your server sends a dialog or banner already in its open state, it animates in as the page loads. If you do not want that, add a class that switches off the transition until the page is interactive.
Web Components pair well with this too. Their lifecycle follows the same DOM insert model that triggers the entry. Those parts can keep their rules from leaking into the page with native CSS scoping.
Feature detection: starting-style caniuse and fallbacks
The tempting test is @supports at-rule(@starting-style). Avoid it for now. That at-rule() check only arrived in Chrome 148 and Firefox 157, years after @starting-style itself. Wrap your rules in it, and Safari, plus every older Chrome and Firefox, skips the animation even though it supports it.
In JavaScript, check for the rule’s interface instead:
const hasStartingStyle = 'CSSStartingStyleRule' in window;That interface shipped with the rule itself in Chrome 117, Firefox 129 and Safari 17.5, so the check matches real support. For a CSS-only test, Bram Van Damme built a space-toggle trick for detecting @starting-style with a registered custom property.
Often you need no test at all. Put the final state in the base rule and the initial state in @starting-style. An older browser ignores the block and shows the element in its final state. Everything works, it just appears without motion.
Do not ship a JavaScript timing library and @starting-style for the same component. Pick one path per component, based on your support matrix. Two paths double the bug surface for no gain.
Troubleshooting: why your @starting-style animation is not running
Most “it just appears” bug reports come down to five mistakes:
- Missing
allow-discrete. If the element togglesdisplay, listdisplayin the transition withallow-discrete. Without it, the display change is instant and cancels the rest. - Wrong selector. The block must target the state the element moves INTO. For dialogs, use
dialog[open]. For popovers, use[popover]:popover-open. - Specificity conflict. An ID selector, an inline style or an
!importantrule wins over the starting value. When JavaScript must set a value, set a custom property instead of an inline style. - Missing
overlay. Top-layer elements lose their exit in Chromium withoutoverlayin the transition. - The element was already visible. A class change on a rendered element, for example, never triggers a starting style. The element must be new, or coming back from
display: none.
If all five check out and the close still snaps, you may be testing in Firefox or Safari. Look back at the support table before you debug further.
Bonus: animating to and from auto with interpolate-size
A related property, interpolate-size, pairs well with @starting-style for accordion transitions. Set the interpolate-size: allow-keywords property on the root, and transitions can run between a fixed length and 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;
}
}A <details> element is a good second example. It gets the same treatment with no class toggling. The ::details-content pseudo-element is the panel, so the whole widget runs on CSS:
details::details-content {
block-size: 0;
overflow: hidden;
transition: block-size 300ms ease,
content-visibility 300ms allow-discrete;
}
details[open]::details-content {
block-size: auto;
}Why did max-height always feel wrong?
Because the duration applied to the whole made-up max-height range, not the real content height. Short panels finished early and sat still, long panels got clipped. With interpolate-size: allow-keywords the browser animates to the true auto height, so every panel takes the same time no matter how much text it holds.
auto height, so the timing holds no matter how much text sits inside.This retires the old trick of animating max-height to a huge value, which always had bad timing. Keep the support split in mind, though. interpolate-size is Chromium only, from version 129. In Firefox and Safari, the panel simply opens at full height without the transition.
Botmonster Tech