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, with @starting-style, transition-behavior: allow-discrete, and interpolate-size. 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.
Here are both halves working together. The toggle is a bare checkbox, the card is its sibling, and nothing else is wired up: no script, no class toggle, no setTimeout. Entry runs from @starting-style, exit waits on allow-discrete before display: none lands.
Fades and lifts in on entry, then fades out before display: none removes it. Toggle it again to watch the exit.
/* the toggle is a checkbox; the card is its sibling */
.card {
opacity: 1;
transform: translateY(0) scale(1);
transition:
opacity 0.3s ease,
transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
display 0.3s allow-discrete;
}
/* hidden state: display:none, but allow-discrete holds the exit */
.toggle:not(:checked) ~ .card {
display: none;
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
/* entry: the frame the card animates in FROM */
@starting-style {
.toggle:checked ~ .card {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
}
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, and any sizing property that accepts keyword values.
The panel below transitions between height: 0 and height: auto in both directions. Open it, then read the two lines it reveals, then close it: the same auto that used to snap now eases. interpolate-size is scoped to the demo wrapper here rather than :root, so it can’t leak into the rest of the page.
Because the browser had no numeric target to interpolate toward: auto is resolved at layout time, not a value the transition engine could tween. So it snapped to the final height instantly.
interpolate-size: allow-keywords tells it to resolve auto to a pixel height and animate to that. Nothing else changes, the markup stays a plain toggle.
.wrap {
interpolate-size: allow-keywords; /* scoped, not on :root */
}
.panel {
height: 0;
overflow: hidden;
transition: height 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.toggle:checked ~ .panel {
height: auto; /* now it eases, open and close */
}
Combining everything: a pure-CSS accordion
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 | - | 18 |
Firefox landed @starting-style and transition-behavior in v129. interpolate-size shipped in Safari 18 (September 2024) but is still missing in Firefox. 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 and physics-based motion still lean on JavaScript, and scroll-driven effects have their own dedicated CSS API. But for the majority of UI show/hide patterns, these three are now the right tool.