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 requestAnimationFrame pattern: more reliable than setTimeout, but still imperative, still JavaScript, and still breaks with React Server Components and streaming HTML.
  • The transitionend listener: to fade an element before hiding it, you listen for transitionend, then set display: none by 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:

StateSourceWhen
Starting state@starting-style declarationsFirst render / becoming visible
Active stateNormal CSS rulesAfter entry transition completes
Exit stateRemoval or display: none rulesWhen hiding or removing
Diagram showing element lifecycle with @starting-style: from hidden state through starting-style application, entry transition to active state, and reverse transition on close

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.

Flow diagram showing how a native dialog opens and closes with @starting-style: open flow from showModal() through starting-style to transition to final state, and close flow from close()/Escape through reverse transition with overlay holding to hidden state

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.

FeatureBest forScopeRequires JS?
@starting-style + transitionsEntry/exit animations for individual elements (dialogs, popovers, tooltips, conditional components)Single elementNo
View Transitions APIPage-level or route-level transitions, morphing one element into another across navigationEntire view or named elementsYes (minimal)
Web Animations APIComplex sequenced animations, pause/resume/reverse control, physics-based motionAny elementYes
Decision diagram comparing @starting-style for single element show/hide, View Transitions API for page/route transitions, and Web Animations API for complex sequenced animations

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:

BrowserVersionRelease Date
Chrome117August 2023
Edge117August 2023
Safari17.4March 2024
Firefox129August 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:

  1. Missing transition-behavior: allow-discrete. If the element toggles display, you must list display in the transition with allow-discrete. Without it, the display change is instant and cancels all other transitions.

  2. Wrong selector in @starting-style. The starting-style block must target the state the element is moving INTO. For dialogs, use dialog[open], not dialog. For popovers, use [popover]:popover-open, not [popover].

  3. Specificity conflict. An ID selector, an inline style set by JavaScript, or an !important rule can override the @starting-style values. Since @starting-style does 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.