CSS @starting-style: The Complete Guide for 2026
Published on
Animating elements from display: none has been one of the most frustrating limitations in CSS for years. Developers resorted to JavaScript hacks, extra wrapper elements, and convoluted class toggling just to fade in a modal. The @starting-style at-rule changes everything. It gives the browser a "from" state for entry transitions, enabling smooth animations on elements that go from hidden to visible—entirely in CSS.
1. The display:none Animation Problem
Before @starting-style, you could not transition an element from display: none to display: block. The display property is discrete—it has no intermediate values. When an element goes from none to block, it appears instantly with no transition.
/* This does NOT work without @starting-style */
.modal {
display: none;
opacity: 0;
transition: opacity 0.3s ease;
}
.modal.open {
display: block;
opacity: 1;
/* opacity snaps to 1 instantly because the element
jumps from display:none to display:block first,
and the browser has no "from" state for opacity */
}
The traditional workaround was a two-step JavaScript process: first set display: block, force a reflow with getComputedStyle(el).opacity, then add the class that triggers the opacity transition. This was fragile, hard to maintain, and required JavaScript for what should be a pure CSS concern.
2. What Is @starting-style?
The @starting-style at-rule defines the property values that an element should transition from when it is first displayed. It tells the browser: "Before you render this element for the first time, pretend it had these styles, then transition to its actual styles."
This applies in two scenarios:
- An element transitions from
display: noneto a visible display value - An element is first added to the DOM
The browser uses the @starting-style values as the "before" snapshot and the element's computed styles as the "after" snapshot, then runs a normal CSS transition between them.
3. Basic Syntax and How It Works
There are two ways to write @starting-style: as a standalone block or nested inside a rule.
Standalone block syntax
.card {
opacity: 1;
transform: translateY(0);
transition: opacity 0.4s ease, transform 0.4s ease;
}
@starting-style {
.card {
opacity: 0;
transform: translateY(20px);
}
}
Nested syntax (recommended)
.card {
opacity: 1;
transform: translateY(0);
transition: opacity 0.4s ease, transform 0.4s ease;
@starting-style {
opacity: 0;
transform: translateY(20px);
}
}
Both are equivalent. The nested syntax is cleaner because it keeps the starting values co-located with the element's styles. When the .card first appears, it begins at opacity: 0 and translateY(20px), then transitions to opacity: 1 and translateY(0).
4. transition-behavior: allow-discrete
The transition-behavior property controls whether discrete properties (like display and overlay) can participate in transitions. By default, they cannot.
.tooltip {
display: block;
opacity: 1;
transition: opacity 0.3s ease,
display 0.3s ease allow-discrete;
@starting-style {
opacity: 0;
}
}
.tooltip[hidden] {
display: none;
opacity: 0;
}
The shorthand allow-discrete can be placed directly in the transition property after each discrete property. Alternatively, use the longhand:
.tooltip {
transition: opacity 0.3s ease, display 0.3s ease;
transition-behavior: allow-discrete;
}
Without allow-discrete, the display property snaps immediately. With it, the browser keeps the element visible for the full duration of the transition, then switches display at the very end (for exit) or at the very start (for entry).
5. Animating from display:none
Here is the complete pattern for animating an element from display: none to visible:
.panel {
display: block;
opacity: 1;
transform: scale(1);
transition: opacity 0.3s ease,
transform 0.3s ease,
display 0.3s ease allow-discrete;
@starting-style {
opacity: 0;
transform: scale(0.95);
}
}
.panel[hidden] {
display: none;
opacity: 0;
transform: scale(0.95);
}
The three pieces work together:
@starting-styledefines the entry animation's "from" statetransitionwithallow-discreteenables the display property to participate- The hidden state (
.panel[hidden]) defines the exit animation's "to" state
Toggle visibility with the HTML hidden attribute:
<button onclick="panel.toggleAttribute('hidden')">Toggle</button>
<div class="panel" id="panel">Animated content</div>
6. Dialog Element Animations
The <dialog> element with its showModal() method benefits enormously from @starting-style. You can now create smooth CSS-only modal animations.
dialog {
opacity: 1;
transform: translateY(0) scale(1);
transition: opacity 0.3s ease,
transform 0.3s ease,
overlay 0.3s ease allow-discrete,
display 0.3s ease allow-discrete;
@starting-style {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
}
/* Exit animation */
dialog:not([open]) {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
/* Backdrop animation */
dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
opacity: 1;
transition: opacity 0.3s ease,
display 0.3s ease allow-discrete;
@starting-style {
opacity: 0;
}
}
dialog:not([open])::backdrop {
opacity: 0;
}
The overlay property in the transition list ensures the dialog stays in the top layer during the exit animation. Without it, the dialog would disappear from the top layer immediately, cutting off the animation.
<dialog id="modal">
<h2>Confirm Action</h2>
<p>Are you sure you want to proceed?</p>
<button onclick="modal.close()">Close</button>
</dialog>
<button onclick="modal.showModal()">Open Modal</button>
7. Popover Animations
The Popover API (popover attribute) pairs perfectly with @starting-style for lightweight, accessible pop-up elements.
[popover] {
opacity: 1;
transform: translateY(0);
transition: opacity 0.25s ease,
transform 0.25s ease,
overlay 0.25s ease allow-discrete,
display 0.25s ease allow-discrete;
@starting-style {
opacity: 0;
transform: translateY(8px);
}
}
/* Exit state: when popover is not open */
[popover]:not(:popover-open) {
opacity: 0;
transform: translateY(8px);
}
<button popovertarget="info">Show Info</button>
<div id="info" popover>
<p>This popover fades and slides in smoothly.</p>
</div>
Because popovers live in the top layer, the overlay transition is important. It keeps the popover rendered during the exit animation instead of vanishing instantly when it leaves the top layer.
8. Working with Different Properties
You can use @starting-style with any transitionable property. Here are common patterns:
Opacity fade
.fade-in {
opacity: 1;
transition: opacity 0.4s ease,
display 0.4s ease allow-discrete;
@starting-style { opacity: 0; }
}
Slide from direction
.slide-up {
transform: translateY(0);
transition: transform 0.3s ease, opacity 0.3s ease,
display 0.3s ease allow-discrete;
@starting-style { opacity: 0; transform: translateY(30px); }
}
.slide-left {
transform: translateX(0);
transition: transform 0.3s ease, opacity 0.3s ease,
display 0.3s ease allow-discrete;
@starting-style { opacity: 0; transform: translateX(100%); }
}
Scale
.zoom-in {
transform: scale(1);
transition: transform 0.25s ease, opacity 0.25s ease,
display 0.25s ease allow-discrete;
@starting-style { opacity: 0; transform: scale(0.8); }
}
Height (with interpolate-size)
/* Enable height animation to/from auto */
:root {
interpolate-size: allow-keywords;
}
.accordion-body {
height: auto;
overflow: hidden;
transition: height 0.3s ease,
display 0.3s ease allow-discrete;
@starting-style { height: 0; }
}
.accordion-body[hidden] {
display: none;
height: 0;
}
9. Entry and Exit Animations
A complete entry/exit animation pattern requires three style declarations:
/* 1. The visible (resting) state */
.notification {
opacity: 1;
transform: translateX(0);
transition: opacity 0.3s ease,
transform 0.3s ease,
display 0.3s ease allow-discrete;
/* 2. The entry "from" state */
@starting-style {
opacity: 0;
transform: translateX(100%);
}
}
/* 3. The exit "to" state */
.notification[hidden] {
display: none;
opacity: 0;
transform: translateX(100%);
}
The entry and exit states do not have to match. You could slide in from the right and fade out downward:
.card {
opacity: 1;
transform: translate(0, 0);
transition: opacity 0.4s ease, transform 0.4s ease,
display 0.4s ease allow-discrete;
/* Enter: slide from right */
@starting-style {
opacity: 0;
transform: translateX(50px);
}
}
/* Exit: fade down */
.card[hidden] {
display: none;
opacity: 0;
transform: translateY(20px);
}
10. CSS-Only Toast Notifications
Toast notifications are an ideal use case for @starting-style. They appear briefly, then disappear—a pattern that previously required JavaScript animation libraries.
.toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 1000;
}
.toast {
background: #1e293b;
color: #e2e8f0;
padding: 0.875rem 1.25rem;
border-radius: 8px;
border-left: 4px solid #3b82f6;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
opacity: 1;
transform: translateX(0);
transition: opacity 0.3s ease,
transform 0.3s ease,
display 0.3s ease allow-discrete;
@starting-style {
opacity: 0;
transform: translateX(100%);
}
}
.toast[hidden] {
display: none;
opacity: 0;
transform: translateX(100%);
}
.toast.success { border-left-color: #22c55e; }
.toast.error { border-left-color: #ef4444; }
.toast.warning { border-left-color: #f59e0b; }
<div class="toast-container">
<div class="toast success">File saved successfully</div>
<div class="toast error">Connection lost</div>
</div>
Add or remove toasts via JavaScript. The CSS handles all animation automatically.
11. Browser Support and Polyfills
@starting-style is Baseline Newly Available as of 2024:
- Chrome 117+ (September 2023)
- Edge 117+ (September 2023)
- Safari 17.5+ (June 2024)
- Firefox 129+ (August 2024)
Because @starting-style is progressive enhancement by nature, unsupported browsers simply skip the entry animation. The element still appears—it just appears instantly. This makes it safe to use in production today without polyfills.
There is no practical CSS polyfill for @starting-style. If you need animated entry transitions in older browsers, use a JavaScript fallback:
// Fallback for browsers without @starting-style
if (!CSS.supports('@starting-style {}')) {
// Add a class after a frame to trigger CSS transitions
function animateIn(el) {
el.style.display = 'block';
el.classList.add('no-starting-style');
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.classList.add('visible');
});
});
}
}
12. @starting-style vs JavaScript vs WAAPI
There are three main approaches for entry/exit animations. Here is how they compare:
CSS @starting-style
- Zero JavaScript required
- Declarative and co-located with styles
- Works natively with dialog, popover, and the hidden attribute
- Best for standard UI patterns (modals, tooltips, toasts, menus)
- Cannot sequence multi-step animations
JavaScript class toggling
// The old way: two-frame hack
el.style.display = 'block';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.classList.add('visible');
});
});
- Works in all browsers
- Fragile timing, race conditions with other DOM updates
- Requires manual cleanup for exit animations
Web Animations API (WAAPI)
el.animate([
{ opacity: 0, transform: 'translateY(20px)' },
{ opacity: 1, transform: 'translateY(0)' }
], { duration: 300, easing: 'ease', fill: 'forwards' });
- Full programmatic control over timing and sequencing
- Supports dynamic values computed at runtime
- Better for complex, choreographed animations
- Requires JavaScript
Recommendation: Use @starting-style for all standard show/hide UI transitions. Reach for WAAPI only when you need runtime-computed values, multi-step sequences, or animation chaining.
13. Accessibility Considerations
Animated transitions can cause discomfort for users with vestibular disorders. Always respect the prefers-reduced-motion media query:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
Additional accessibility guidelines for entry/exit animations:
- Use the
<dialog>element for modals. It provides built-in focus trapping, Escape key handling, and screen reader semantics. - Use the
popoverattribute for non-modal overlays. It handles light dismiss and accessibility automatically. - Keep animations short. Entry animations under 300ms and exit animations under 200ms feel responsive without causing discomfort.
- Avoid animating layout properties (width, height, top, left) when possible. Stick to
transformandopacityfor smooth 60fps animations that do not trigger layout recalculations. - Do not rely on animation to convey information. The content should be understandable even if animations are disabled.
14. Real-World Patterns and Recipes
Dropdown menu
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
opacity: 1;
transform: translateY(0);
transition: opacity 0.2s ease,
transform 0.2s ease,
display 0.2s ease allow-discrete;
@starting-style {
opacity: 0;
transform: translateY(-8px);
}
}
.dropdown-menu[hidden] {
display: none;
opacity: 0;
transform: translateY(-8px);
}
Sidebar drawer
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: 300px;
transform: translateX(0);
transition: transform 0.3s ease,
display 0.3s ease allow-discrete;
@starting-style {
transform: translateX(-100%);
}
}
.sidebar[hidden] {
display: none;
transform: translateX(-100%);
}
Tooltip with popover
[popover].tooltip {
margin: 0;
padding: 0.5rem 0.75rem;
background: #1e293b;
color: #e2e8f0;
border-radius: 6px;
font-size: 0.875rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
opacity: 1;
transform: scale(1);
transition: opacity 0.15s ease,
transform 0.15s ease,
overlay 0.15s ease allow-discrete,
display 0.15s ease allow-discrete;
@starting-style {
opacity: 0;
transform: scale(0.9);
}
}
[popover].tooltip:not(:popover-open) {
opacity: 0;
transform: scale(0.9);
}
List item stagger effect
.list-item {
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s ease, transform 0.3s ease;
@starting-style {
opacity: 0;
transform: translateY(15px);
}
}
/* Stagger with transition-delay */
.list-item:nth-child(1) { transition-delay: 0ms; }
.list-item:nth-child(2) { transition-delay: 50ms; }
.list-item:nth-child(3) { transition-delay: 100ms; }
.list-item:nth-child(4) { transition-delay: 150ms; }
.list-item:nth-child(5) { transition-delay: 200ms; }
Animated alert banner
.alert {
padding: 1rem 1.25rem;
border-radius: 8px;
opacity: 1;
transform: translateY(0);
max-height: 200px;
transition: opacity 0.3s ease,
transform 0.3s ease,
max-height 0.3s ease,
display 0.3s ease allow-discrete;
@starting-style {
opacity: 0;
transform: translateY(-10px);
max-height: 0;
}
}
.alert[hidden] {
display: none;
opacity: 0;
transform: translateY(-10px);
max-height: 0;
}
Frequently Asked Questions
What is CSS @starting-style and what problem does it solve?
CSS @starting-style is an at-rule that defines the initial property values an element should transition from when it first appears on the page or changes from display: none to a visible state. It solves the long-standing problem of not being able to animate elements into view with CSS alone. Before @starting-style, transitioning from display: none required JavaScript workarounds because the browser had no concept of a "before" state to transition from.
What is transition-behavior: allow-discrete and why is it needed?
transition-behavior: allow-discrete is a CSS property that enables transitions on discrete properties like display and visibility, which normally switch instantly between values. Without it, display: none to display: block would still snap instantly even with @starting-style defined. You need both @starting-style (to define the from state) and transition-behavior: allow-discrete (to allow the display property itself to participate in the transition) for smooth entry and exit animations.
Which browsers support CSS @starting-style?
As of early 2026, @starting-style is supported in Chrome 117+, Edge 117+, Safari 17.5+, and Firefox 129+. It is considered Baseline Newly Available. For browsers that do not support it, elements still appear and function correctly but without the entry animation, making it a safe progressive enhancement.
Can @starting-style animate elements both into and out of the DOM?
Yes, but entry and exit animations work differently. @starting-style handles the entry animation by defining the initial state to transition from. For exit animations, you define the final state on the element's hidden state (e.g., [popover]:not(:popover-open) or dialog:not([open])). Combined with transition-behavior: allow-discrete and overlay in your transition, this creates smooth two-way animations entirely in CSS.
How does @starting-style compare to JavaScript animation approaches?
@starting-style is purely declarative CSS and requires no JavaScript, making it simpler for common entry/exit patterns. It integrates naturally with the popover API and dialog elements. The Web Animations API (WAAPI) offers more control over timing, sequencing, and dynamic values. For most UI patterns like modals, tooltips, popovers, and toasts, @starting-style is sufficient and results in less code. Use WAAPI when you need programmatic control, animation chaining, or runtime-computed values.