Transitioning display with transition-behavior: allow-discrete and @starting-style

For years one animation stayed stubbornly out of reach in pure CSS: smoothly removing an element from the page. You could fade something in, but fading it out and then setting display: none meant the element vanished the instant the display value changed, cutting the exit transition short. Developers reached for JavaScript setTimeout calls timed to the transition, or kept invisible elements in the layout forever. This guide, part of the CSS Transition Fundamentals section, shows how transition-behavior: allow-discrete together with @starting-style finally make JavaScript-free enter and exit transitions possible, even when the element toggles to and from display: none.

The exact problem: a popover, dropdown, or modal that should fade and scale in when shown, and fade and scale out before leaving the document — with no script orchestrating the timing.


Why this is a CSS problem and not a JavaScript one

Two distinct obstacles block a CSS-only exit. First, display is a discrete (non-interpolatable) property. There is no halfway point between block and none, so by default the browser flips it at the start of the transition, removing the element before opacity has a chance to animate. Second, when an element first appears — whether on initial render or when it switches from display: none to display: block — there is no previous computed style to transition from, so the browser jumps straight to the final state and the entry animation never plays.

The platform now solves both directly. transition-behavior: allow-discrete tells the engine to defer the discrete flip until the end of the transition, keeping display: block applied while opacity and transform animate, then switching to none on the final frame. @starting-style supplies the "before" computed values an element should animate from the first time it becomes visible. Used together they cover the full lifecycle without a single line of script, which keeps the interaction resilient, accessible by default, and free of the timing drift that setTimeout-based approaches always risk. The behaviour still degrades cleanly: where the features are absent, the element simply appears and disappears at once.


The enter and exit timeline

The diagram traces what the browser does across an entry and an exit when both features are in play. On entry it reads the @starting-style values, then transitions to the visible state. On exit it animates the visible properties while holding display, flipping it to none only at the end.

Enter and exit transition timeline A two-track timeline showing entry using @starting-style and exit using allow-discrete, with the discrete display flip held until the final frame. Enter / exit lifecycle ENTER @starting-style opacity:0; scale .9 visible state opacity:1; scale 1 EXIT visible, display:block animate opacity out display:none held to last frame

A complete working implementation: a CSS-only popover

The example uses the native popover attribute so there is genuinely zero JavaScript — the button toggles the popover, and CSS handles both transitions. The same technique applies to [open] toggles, :has()-driven panels, or the native <dialog>.

<button popovertarget="menu" class="trigger">Open menu</button>

<div id="menu" popover class="pop">
  <p class="pop__title">Account</p>
  <button type="button">Profile</button>
  <button type="button">Sign out</button>
</div>
.pop {
  /* Visible (open) state — what we transition TO on enter, FROM on exit */
  opacity: 1;
  transform: translateY(0) scale(1);

  /* allow-discrete lets display (and the top-layer 'overlay') animate.
     Listing them in transition-property keeps the element rendered
     until the opacity/transform animation finishes. */
  transition:
    opacity 220ms ease,
    transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1),
    overlay 220ms ease allow-discrete,
    display 220ms ease allow-discrete;
}

/* Closed state: a popover that is not open computes to display:none */
.pop:not(:popover-open) {
  opacity: 0;
  transform: translateY(-8px) scale(0.96);
}

/* ENTER: the values the popover animates FROM the first frame it shows.
   Nested inside the selector so it only applies to .pop. */
@starting-style {
  .pop:popover-open {
    opacity: 0;
    transform: translateY(-8px) scale(0.96);
  }
}

.trigger { padding: 0.5rem 1rem; }
.pop { padding: 0.75rem; border-radius: 10px; border: 1px solid currentColor; }

/* Respect motion preferences: skip the animation, keep the toggle */
@media (prefers-reduced-motion: reduce) {
  .pop { transition: none; }
}

When the button opens the popover, the engine reads the @starting-style block, paints the popover at opacity: 0 and slightly raised, then transitions it to its visible state. When it closes, opacity and transform animate back out while allow-discrete keeps display (and the top-layer overlay property) applied until the final frame — so the exit is fully visible before the element leaves the document.


The key technique: deferring the discrete switch

The mechanism that does the heavy lifting is the allow-discrete keyword on transition-behavior (here written inline in the transition shorthand). Discrete properties such as display normally toggle at time zero. allow-discrete changes the rule to: keep the before value applied for the whole duration when transitioning toward a hidden state, and flip to the after value only on the last frame. That single change is what keeps the element in the box-tree long enough for opacity to reach zero before display: none takes effect. Pairing it with @starting-style — which manufactures the missing "from" state on first render — closes the loop and gives you symmetric enter and exit motion.


Variation: a native dialog with a backdrop

The same two features animate a <dialog> and its ::backdrop, which live in the top layer. Because the dialog's display and overlay are discrete, both need allow-discrete.

dialog {
  opacity: 1;
  transform: scale(1);
  transition:
    opacity 200ms ease,
    transform 200ms ease,
    overlay 200ms ease allow-discrete,
    display 200ms ease allow-discrete;
}
dialog:not([open]) { opacity: 0; transform: scale(0.95); }

@starting-style {
  dialog[open] { opacity: 0; transform: scale(0.95); }
}

dialog::backdrop {
  background: rgb(0 0 0 / 0.4);
  transition: background 200ms ease, overlay 200ms ease allow-discrete, display 200ms ease allow-discrete;
}
@starting-style {
  dialog[open]::backdrop { background: rgb(0 0 0 / 0); }
}

Browser support note

transition-behavior: allow-discrete and @starting-style shipped together in Chrome and Edge 117+, Safari 17.4+, and Firefox 129+, so as of mid-2026 they are available across all evergreen browsers. The degradation is naturally safe: any engine that does not recognise them ignores the discrete part of the transition, so the element appears and disappears instantly while remaining fully functional. If you want to be explicit you can guard enhancement behind @supports (transition-behavior: allow-discrete) { ... }, but it is rarely necessary because the unsupported path is already acceptable.


FAQ

Why does my element disappear instantly instead of fading out? Because display is a discrete property that flips at the start of the transition by default, removing the element before opacity can animate. Add allow-discrete to the display (and overlay) entry in your transition so the browser holds the visible display value until the animation finishes, then switches to none on the last frame.

What is @starting-style used for?@starting-style defines the computed values an element transitions from the first time it renders or when it changes from display: none to a visible state. Without it there is no "before" state to interpolate from, so the entry transition is skipped and the element simply pops in.

Which browsers support transition-behavior and @starting-style? Both shipped together in Chrome and Edge 117+, Safari 17.4+, and Firefox 129+. In older browsers the element appears and disappears instantly, which is a safe functional fallback rather than a broken state.

Do I still need keyframes or JavaScript for modal enter and exit animations? No. With allow-discrete and @starting-style you can transition opacity and transform on both entry and exit entirely in CSS, including the discrete display and the top-layer overlay properties used by dialogs and popovers.


Related articles

More pages in the same section.