CSS @starting-style: animate elements when they appear
CSS finally has a native way to transition elements when they first appear on the page. The feature is called @starting-style, and it solves one of those small but frustrating problems that developers have been working around with JavaScript, animation classes, and timing tricks for years.
Think about notifications that fade in when they are added to the page, dropdowns that smoothly appear when opened, or dialogs that animate instead of suddenly popping into view. These are all common interface patterns, but regular CSS transitions have historically had a problem with them. A transition needs a starting state and an ending state, and when an element first appears, the browser doesn’t always have a previous state to transition from.
That’s where @starting-style comes in.
The problem
CSS transitions work well when an element is already visible and one of its properties changes. For example:
.card {
opacity: 1;
transition: opacity 250ms ease;
}
.card.faded {
opacity: 0.5;
}
When the faded class is added, the card smoothly changes its opacity. Remove the class, and it transitions back. Simple enough.
But what happens when the element wasn’t visible before? Imagine a notification that is created after someone saves their settings. You add a new element to the DOM and want it to fade in. You might try:
.notification {
opacity: 1;
transition: opacity 250ms ease;
}
And then add it using JavaScript:
const notification = document.createElement("div");
notification.className = "notification";
notification.textContent = "Settings saved.";
document.body.append(notification);
The notification appears, but it doesn’t fade in. The problem is that the browser never saw the element at opacity: 0. It was created, added to the DOM, and rendered with opacity: 1, so there is no previous computed style to transition from.
A common workaround is to add the element with a hidden state, wait for the browser to render it, and then remove that state. Sometimes that means using requestAnimationFrame(), sometimes a timeout, and sometimes a framework-specific transition component. It works, but it feels like a lot of coordination for something as simple as making an element appear smoothly.
CSS @starting-style gives us a way to define that missing starting state.
The basic idea
The @starting-style at-rule defines the styles an element should transition from when it first becomes rendered. For example:
.notification {
opacity: 1;
transition: opacity 250ms ease;
}
@starting-style {
.notification {
opacity: 0;
}
}
Now, when a notification is added to the DOM after the page has loaded, the browser can transition its opacity from 0 to 1. We don’t need to add a separate hidden class or wait for another rendering frame.
The regular .notification rule defines the final appearance, while @starting-style defines the starting values for the entry transition. It doesn’t create an animation by itself, though. You still need a transition declaration to tell the browser which properties should change and how long the transition should take.
One thing worth noting is that @starting-style doesn’t automatically animate every element during the initial page load. Its starting styles are used when an element has no previous rendered style to transition from, such as when it’s inserted dynamically or changes from display: none to a visible state.
Animating newly added elements
Notifications are probably one of the clearest examples of where @starting-style is useful. Let’s make the animation a little more interesting by combining opacity with a vertical movement.
.notification {
opacity: 1;
transform: translateY(0);
transition:
opacity 250ms ease,
transform 250ms ease;
@starting-style {
opacity: 0;
transform: translateY(1rem);
}
}
When the notification is added, it starts slightly lower and transparent, then moves into place while fading in. The JavaScript can stay exactly the same:
const notification = document.createElement("div");
notification.className = "notification";
notification.textContent = "Settings saved.";
document.querySelector(".notifications").append(notification);
JavaScript is still responsible for creating the notification, but CSS handles the entry transition. No extra class changes, no timeout, and no need to wait for the next animation frame.
One important limitation is that @starting-style only handles the starting state when an element appears. It doesn’t automatically animate the element when it is removed from the DOM. If you call notification.remove(), the element disappears immediately. For a smooth exit, you still need to change its state before removing it, or use another mechanism that allows the exit transition to finish.
We’ll look at another way to handle hiding elements next.
Animating display: none
Another common problem is transitioning elements between display: none and a visible state. For example, imagine a small information panel that can be shown or hidden.
You might write:
.panel {
display: block;
opacity: 1;
transition: opacity 250ms ease;
}
.panel.hidden {
display: none;
opacity: 0;
}
The problem is that display doesn’t behave like opacity. When an element changes to display: none, it stops being rendered, so the opacity transition doesn’t get a chance to finish. And when it changes back to display: block, it appears immediately because the browser doesn’t have a previous visible style to transition from.
To solve this properly, we need two features. The first is @starting-style, which provides the entry state. The second is transition-behavior: allow-discrete, which lets us include properties such as display in transitions.
.panel {
display: block;
opacity: 1;
transition:
opacity 250ms ease,
display 250ms;
transition-behavior: allow-discrete;
}
.panel.hidden {
display: none;
opacity: 0;
}
@starting-style {
.panel {
opacity: 0;
}
}
Now the panel can fade in when it becomes visible and fade out when it is hidden. The important detail is how the browser handles display. It doesn’t gradually interpolate between none and block, because that wouldn’t really make sense. Instead, the browser changes the value at a specific point in the transition.
When an element transitions from display: none to a visible display value, it becomes visible at the beginning of the transition. When it transitions from a visible display value to none, it stays rendered until the transition finishes. This gives the opacity transition time to complete in both directions, and once the exit transition finishes, the element stops taking up space in the layout.
We can extend the same example with a small movement:
.panel {
display: block;
opacity: 1;
transform: translateY(0);
transition:
opacity 250ms ease,
transform 250ms ease,
display 250ms;
transition-behavior: allow-discrete;
@starting-style {
opacity: 0;
transform: translateY(0.75rem);
}
}
.panel.hidden {
display: none;
opacity: 0;
transform: translateY(0.75rem);
}
The panel now fades and slides in when opened, then fades and slides out before being hidden. Notice that the hidden state and the starting state have the same opacity and transform values, but they serve different purposes. The hidden state defines where the element transitions when it closes, while the starting state defines where it transitions from when it opens. You can make them different if you want different entrance and exit effects.
This is one of the parts I find most useful. We can now write normal transitions for elements that don’t always exist in the rendered layout, without manually delaying display: none until an animation finishes.
Animating native popovers
This is where @starting-style gets especially interesting. In my previous article about CSS anchor positioning, I showed how we can position popovers relative to their triggers without JavaScript calculations.
The native Popover API already handles opening, closing, light dismissal, and top-layer placement. But what if we also want the popover to animate when it opens and closes?
Let’s start with a basic popover:
<button popovertarget="options-menu">
Options
</button>
<div id="options-menu" popover>
<button>Edit</button>
<button>Duplicate</button>
<button>Delete</button>
</div>
The popovertarget attribute connects the button to the popover. Clicking the button opens and closes it, while pressing Escape or clicking outside an automatically dismissible popover closes it too. No JavaScript is needed for that behavior.
Now let’s add an entry and exit transition:
[popover] {
opacity: 0;
transform: translateY(0.5rem);
transition:
opacity 250ms ease,
transform 250ms ease,
display 250ms,
overlay 250ms;
transition-behavior: allow-discrete;
}
[popover]:popover-open {
opacity: 1;
transform: translateY(0);
@starting-style {
opacity: 0;
transform: translateY(0.5rem);
}
}
There are a few things happening here. The default [popover] rule defines how the popover should look when it is closing, while the :popover-open rule defines its visible state. The nested @starting-style rule provides the starting values for the opening transition.
Notice that we also included two properties that we haven’t used before:
display 250ms,
overlay 250ms;
The display transition keeps the popover rendered until its exit transition finishes. The overlay transition keeps it in the browser’s top layer until the transition is complete. That second part matters because popovers are rendered in the top layer when open, and without delaying their removal from it, a closing popover could move back into its normal stacking context before the animation finishes.
You don’t set an overlay value yourself. Including it in the transition is enough, and transition-behavior: allow-discrete makes these discrete transitions possible.
This pairs really nicely with anchor positioning. Anchor positioning handles where the popover appears, the Popover API handles opening and closing, and @starting-style helps CSS handle how it appears. Three separate browser features working together, without a JavaScript positioning or animation library.
What about dialogs?
The same idea works with native <dialog> elements. A dialog is another element that changes between hidden and visible states, and modal dialogs are displayed in the top layer. That makes @starting-style, display, and overlay useful here too.
For example:
dialog {
opacity: 0;
transform: translateY(1rem) scale(0.98);
transition:
opacity 250ms ease,
transform 250ms ease,
display 250ms,
overlay 250ms;
transition-behavior: allow-discrete;
}
dialog[open] {
opacity: 1;
transform: translateY(0) scale(1);
@starting-style {
opacity: 0;
transform: translateY(1rem) scale(0.98);
}
}
When the dialog is opened using showModal(), it fades in and moves into place. When it closes, the reverse transition can finish before the dialog is removed from the top layer.
We can also animate the dialog backdrop:
dialog::backdrop {
background-color: transparent;
transition:
background-color 250ms ease,
display 250ms,
overlay 250ms;
transition-behavior: allow-discrete;
}
dialog[open]::backdrop {
background-color: rgb(0 0 0 / 35%);
}
@starting-style {
dialog[open]::backdrop {
background-color: transparent;
}
}
Now the background behind the modal can fade in and out along with the dialog. This is a nice improvement over dialogs that simply appear or disappear instantly.
Of course, @starting-style doesn’t handle dialog behavior. It doesn’t decide when a dialog should open, where focus goes, or how the user confirms an action. That’s still the job of the native dialog API and your application logic. CSS only handles the visual transition.
Two ways to write @starting-style
So far, we’ve used both the standalone and nested versions of @starting-style. You can write it as a separate block:
.notification {
opacity: 1;
transition: opacity 250ms ease;
}
@starting-style {
.notification {
opacity: 0;
}
}
Or you can nest it inside the original rule:
.notification {
opacity: 1;
transition: opacity 250ms ease;
@starting-style {
opacity: 0;
}
}
Both approaches work. I like the nested version for small components because it keeps the starting state close to the styles it belongs to. For more complicated components, especially when several selectors are involved, I would probably use a separate block.
There is also a small cascade detail worth remembering. When using a standalone @starting-style block, place it after the corresponding regular rule if both have the same specificity. Otherwise, the later normal declaration can override the starting value and prevent the transition from working as expected. Using the nested syntax avoids that particular ordering problem.
A practical default
For most projects, I wouldn’t try to animate every element that appears or disappears. I would create a small reusable pattern for components that benefit from an entry transition.
For simple notifications or dynamically inserted elements, something like this is usually enough:
.fade-in {
opacity: 1;
transform: translateY(0);
transition:
opacity 200ms ease,
transform 200ms ease;
@starting-style {
opacity: 0;
transform: translateY(0.5rem);
}
}
For a popover that also needs to animate when closing, I would include the discrete transitions:
.animated-popover {
opacity: 0;
transform: translateY(0.5rem);
transition:
opacity 200ms ease,
transform 200ms ease,
display 200ms,
overlay 200ms;
transition-behavior: allow-discrete;
}
.animated-popover:popover-open {
opacity: 1;
transform: translateY(0);
@starting-style {
opacity: 0;
transform: translateY(0.5rem);
}
}
Then use it with a native popover:
<button popovertarget="settings">
Settings
</button>
<div
id="settings"
class="animated-popover"
popover
>
<p>Popover content</p>
</div>
The animation stays separate from the interaction, which I think is an important distinction. We don’t need to build a custom interaction system just because we want a small visual effect. Use the browser’s built-in behavior where possible, and let CSS handle the transition.
Progressive enhancement
@starting-style has been supported across the major browser engines since 2024, including Chrome, Firefox, Safari, and Edge. Older browsers may still lack support for it or for discrete transitions.
You can check the full @starting-style documentation on MDN and current browser support on Can I Use.
I would treat this as progressive enhancement. If a browser doesn’t support @starting-style, it will ignore the at-rule, and the element can still appear without the entry transition. If it doesn’t support discrete transitions, the element may disappear immediately instead of waiting for the exit animation to finish.
For most interfaces, that’s an acceptable fallback. The important part is that the content still appears, the popover still works, and the dialog remains usable. I wouldn’t make essential content depend on an entry animation or hide content by default in a way that requires the animation to reveal it.
It’s fine for an element to appear instantly in an older browser. That’s much better than an animation that accidentally prevents users from seeing it at all.
Respecting reduced motion
Just because we can animate something doesn’t mean we always should. Small fades and subtle movements can make an interface feel smoother, but excessive animation can be distracting or uncomfortable.
For decorative transitions, we should respect the user’s reduced-motion preference:
@media (prefers-reduced-motion: reduce) {
.fade-in,
.animated-popover {
transition: none;
}
}
This removes the visual transitions for users who have requested reduced motion, while the elements still open, close, appear, and disappear normally.
I would also keep entry transitions relatively short. Something around 150–250 milliseconds is usually enough for a notification or small popover. A dialog might benefit from a slightly different transition, but there’s rarely a reason to make people wait through a long animation just to interact with a button.
The animation should support the interface, not become the main event.
When not to use it
@starting-style is useful, but it doesn’t replace every animation technique or every piece of JavaScript. If you need a complex animation sequence, multiple coordinated steps, spring-based movement, or animations that depend on application state, JavaScript animation libraries or the Web Animations API may still be a better choice.
Also, @starting-style is specifically about CSS transitions. If you’re already using CSS keyframe animations, you usually don’t need it to define the beginning of an animation because the keyframes already do that.
And remember that @starting-style doesn’t automatically handle exit transitions for elements removed from the DOM. If you remove an element immediately, there is nothing left to animate. It is most useful when an element is first rendered, changes from display: none to a visible state, or opens as a native popover or dialog.
I would also avoid using entry animations on every card, paragraph, button, and section just because the feature makes it easier. An interface where everything is constantly fading and moving can quickly become more distracting than helpful. Notifications, small panels, menus, and dialogs are good examples of where the appearance of an element is meaningful enough to animate.
To recap
CSS @starting-style lets us define the starting values for a transition when an element first appears. The basic pattern is:
.element {
opacity: 1;
transition: opacity 250ms ease;
@starting-style {
opacity: 0;
}
}
For elements that change between visible and display: none, we can combine it with transition-behavior: allow-discrete. And for popovers and modal dialogs, including display and overlay in the transition lets the browser keep them visible and in the top layer until their exit transitions finish.
Like CSS field-sizing, CSS anchor positioning, and CSS scroll-driven animations, this is another example of the browser handling something that previously needed extra JavaScript or workarounds.
For me, the best part is that we no longer need to coordinate rendering frames just to make a newly added element fade in. The browser already knows when an element becomes visible, and with @starting-style, we can tell it how that element should start its transition.
It’s a small feature, but it makes one more common frontend pattern simpler. And that’s exactly the kind of CSS improvement I like.