For years, two problems have forced developers to reach for JavaScript:
- You can’t animate an element entering or leaving the DOM (or toggling
display: none) - You can’t animate
height: auto
Both are solved in CSS now. No libraries, no JS, no hacks.
The problem with display: none
When you toggle display: none, the browser skips transitions entirely. The element just snaps. The workaround — animating opacity and visibility together, using pointer-events: none, or leaning on JavaScript to add/remove classes with setTimeout — has been a rite of passage for frontend developers since forever.
Three new CSS features change this completely.
@starting-style — animate the entry
@starting-style defines the styles an element should have before it first renders. The browser transitions from those starting styles to the element’s normal styles on entry.
.popover {
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s ease, transform 0.3s ease;
}
@starting-style {
.popover {
opacity: 0;
transform: translateY(-8px);
}
}
When .popover appears in the DOM (or becomes visible), it animates in from opacity: 0 and a slight upward offset. No JavaScript. No class toggling. No setTimeout.
transition-behavior: allow-discrete — animate the exit
Entry is handled. But display: none exits still snap — because display is a discrete property (not interpolable). transition-behavior: allow-discrete opts in to transitioning discrete properties:
.popover {
display: block;
opacity: 1;
transform: translateY(0);
transition:
opacity 0.3s ease,
transform 0.3s ease,
display 0.3s allow-discrete;
}
.popover:not([open]) {
display: none;
opacity: 0;
transform: translateY(-8px);
}
@starting-style {
.popover[open] {
opacity: 0;
transform: translateY(-8px);
}
}
With allow-discrete, the browser lets the transition complete before applying display: none. The element fades out, then disappears — instead of snapping immediately.
The shorthand transition: all 0.3s doesn’t cover discrete properties. You have to be explicit.
A real-world example: the <dialog> element
The native <dialog> element is a perfect playground. It uses display: none / display: block internally, and it has a ::backdrop pseudo-element.
dialog {
opacity: 1;
transform: scale(1);
transition:
opacity 0.25s ease,
transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1),
display 0.25s allow-discrete,
overlay 0.25s allow-discrete;
}
dialog:not([open]) {
opacity: 0;
transform: scale(0.95);
}
@starting-style {
dialog[open] {
opacity: 0;
transform: scale(0.95);
}
}
dialog::backdrop {
background: rgba(0, 0, 0, 0);
transition:
background 0.25s ease,
display 0.25s allow-discrete,
overlay 0.25s allow-discrete;
}
dialog[open]::backdrop {
background: rgba(0, 0, 0, 0.5);
}
@starting-style {
dialog[open]::backdrop {
background: rgba(0, 0, 0, 0);
}
}
overlay is another discrete property — it controls whether the element is in the top layer (where dialogs live). Transitioning it keeps the dialog in the top layer during the exit animation so it doesn’t disappear beneath other content mid-fade.
interpolate-size — animate height: auto
The other eternal CSS frustration: you can’t transition to or from auto. This has always required JavaScript to measure the element’s scrollHeight and set an explicit pixel value before animating.
/* This has never worked — until now */
.accordion-body {
height: 0;
overflow: hidden;
transition: height 0.3s ease;
}
.accordion-body.open {
height: auto; /* snaps, no transition */
}
interpolate-size: allow-keywords tells the browser to interpolate keyword sizing values like auto, min-content, max-content, and fit-content:
:root {
interpolate-size: allow-keywords;
}
.accordion-body {
height: 0;
overflow: hidden;
transition: height 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.accordion-body.open {
height: auto; /* now animates */
}
Setting it on :root opts in the entire document. You can also scope it to a specific component if you want to be conservative.
This works for width, block-size, inline-size — any sizing property that accepts keyword values.
Combining everything: an animated disclosure
Here’s a full accordion built with zero JavaScript:
details {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
overflow: hidden;
interpolate-size: allow-keywords;
}
details > div {
height: 0;
overflow: hidden;
transition: height 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
details[open] > div {
height: auto;
}
@starting-style {
details[open] > div {
height: 0;
}
}
The <details> / <summary> element handles the open/close toggle natively. @starting-style provides the entry animation. interpolate-size handles the height: auto transition. The exit animates back to height: 0 naturally.
Browser support
| Feature | Chrome | Firefox | Safari |
|---|---|---|---|
@starting-style | 117 | 129 | 17.5 |
transition-behavior | 117 | 129 | 17.5 |
interpolate-size | 129 | — | TP |
Firefox landed @starting-style and transition-behavior in v129. interpolate-size is still Chrome-only at the time of writing, with Safari Technology Preview support. Use @supports for production:
@supports (interpolate-size: allow-keywords) {
:root {
interpolate-size: allow-keywords;
}
}
What this replaces
| Before | After |
|---|---|
JS class toggle + setTimeout for entry | @starting-style |
JS scrollHeight measurement for height | interpolate-size: allow-keywords |
Animating display: none exits | transition-behavior: allow-discrete |
| Framer Motion / GSAP for basic show/hide | All three together |
These three features don’t cover every animation use case — complex sequencing, scroll-driven choreography, and physics-based motion still belong to JavaScript. But for the majority of UI show/hide patterns, CSS is now the right tool.