CSS Entry & Exit Animations Without JavaScript

How @starting-style, transition-behavior: allow-discrete, and interpolate-size finally solve the animations JavaScript used to own — animating display: none and height: auto in pure CSS.

For years, two problems have forced developers to reach for JavaScript:

  1. You can’t animate an element entering or leaving the DOM (or toggling display: none)
  2. 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

FeatureChromeFirefoxSafari
@starting-style11712917.5
transition-behavior11712917.5
interpolate-size129TP

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

BeforeAfter
JS class toggle + setTimeout for entry@starting-style
JS scrollHeight measurement for heightinterpolate-size: allow-keywords
Animating display: none exitstransition-behavior: allow-discrete
Framer Motion / GSAP for basic show/hideAll 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.