Exit Animations for Removed DOM Elements

Part of CSS @starting-style & Entry Effects in Modern View Transitions & Scroll APIs.

The problem

Entry animations have become easy: @starting-style animates an element the moment it appears. Exits have not. Dismissing a notification calls node.remove(), and the notification vanishes on the next frame no matter what CSS says. A filter hides non-matching cards by removing them from the list, and the grid snaps into a new arrangement. A chip deleted from an input is gone before the user’s eye reaches it.

The reason is simple: an animation is attached to an element’s style, and a removed element has no style. Every working exit pattern keeps something present long enough to animate.

Root cause analysis: something must exist during the exit

There are four ways to make “something” exist, and they differ in what that something is.

1. Keep the element, then remove it. Change a state attribute that triggers an exit transition, wait for it to settle, then remove the node. The element is fully live during the exit — it still receives events unless disabled, and it still occupies layout. Waiting must handle cancellation and reduced motion, as in handling animationend and transitionend reliably.

2. Hide it instead of removing it. If the element can stay in the DOM, toggling display: none with transition-behavior: allow-discrete keeps it rendered until the exit transition ends, then removes its box. No script waiting is needed, and re-showing it uses @starting-style. This is the pattern in discrete and intrinsic-size animation.

3. Animate a snapshot. document.startViewTransition(() => node.remove()) captures the page before and after the removal and animates between the two images. The removed element exists only as a snapshot in the old image, and the rest of the layout can animate into its new position. No waiting code, no lingering node — but the effect is a cross-fade or morph of images, not a live element.

4. Top-layer exits. Popovers and dialogs leave the top layer when closed. Transitioning overlay and display with allow-discrete keeps them in the top layer and rendered until their exit transition finishes, covered in animating popover and dialog entry and exit.

Four exit patterns comparedPick by whether the node must leave the DOM and whether siblings must reflow smoothly.Four exit patterns comparedNode leaves DOMScript waitingSiblings animateLive element duringexitAnimate then removeYesYesNo, unless FLIPYesdisplay: none +allow-discreteNoNoOnly with sizeanimationYesView transitionaround removalYesNoYesSnapshot onlyTop-layer overlayexitNo (closed)NoNot applicableYes
Pick by whether the node must leave the DOM and whether siblings must reflow smoothly.

Step-by-step resolution

Choosing and implementing an exitStart from what must happen to the node and its neighbours.Choosing and implementing an exit1If the element can stay in the DOM hidden, use display: none with allow-discrete.Pure CSS exit2If it must be removed and siblings should glide, wrap the removal in a view transition.Snapshot-based exit with reflow3If it must be removed and stay interactive during exit, animate then remove with a settledhelper.Live exit4For popovers and dialogs, transition overlay and display.Top-layer exit5Disable pointer events on exiting elements.No clicks on ghosts6Under reduced motion, remove or hide immediately.
Start from what must happen to the node and its neighbours.

Production code pattern

/* Pattern 2: hide in place. */
.chip {
  opacity: 1;
  scale: 1;
  transition:
    opacity var(--motion-exit-duration) var(--motion-exit-easing),
    scale var(--motion-exit-duration) var(--motion-exit-easing),
    display var(--motion-exit-duration) allow-discrete;
}
.chip[hidden] {
  display: none;
  opacity: 0;
  scale: 0.9;
}
@starting-style {
  .chip:not([hidden]) { opacity: 0; scale: 0.9; }
}

/* Pattern 1: animate then remove. */
.toast[data-state="exiting"] {
  animation: toast-out var(--motion-exit-duration) var(--motion-exit-easing) forwards;
  pointer-events: none;
}
@keyframes toast-out { to { opacity: 0; translate: 0 8px; } }

/* Pattern 3: name the list items so neighbours morph into place. */
.card { view-transition-name: match-element; }
::view-transition-old(*):only-child { animation: vt-fade-out var(--motion-exit-duration) var(--motion-exit-easing) both; }
@keyframes vt-fade-out { to { opacity: 0; scale: 0.96; } }

@media (prefers-reduced-motion: reduce) {
  .chip, .chip[hidden] { transition: none; }
  .toast[data-state="exiting"] { animation: none; }
  ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
}
const reduce = matchMedia('(prefers-reduced-motion: reduce)');

// Pattern 1: live element exits, then leaves.
async function dismissToast(toast) {
  if (reduce.matches) return toast.remove();
  toast.dataset.state = 'exiting';
  await Promise.allSettled(toast.getAnimations().map((a) => a.finished));
  if (toast.dataset.state === 'exiting') toast.remove();
}

// Pattern 3: removal inside a view transition; neighbours reflow smoothly.
function removeCard(card) {
  if (!document.startViewTransition || reduce.matches) return card.remove();
  document.startViewTransition(() => card.remove());
}

Rendering Impact: composite for patterns 1, 2 and 4, which animate opacity, scale and translate; the view transition animates snapshot textures on the compositor, with a texture per named element in both old and new states.

view-transition-name: match-element gives each card an automatic unique name, so remaining cards morph to their new positions and the removed card’s snapshot exists only in the old state — which is exactly what the :only-child selector on ::view-transition-old targets. Automatic naming is newer than manual names; where unsupported, assign unique names from data ids, as described in automatic view transition names.

Choosing an exit patternTwo questions decide most cases.Choosing an exit patternElement exitsMust leave DOM?no: display noneNeighbours reflow?yes: view transitionOtherwiseanimate then remove
Two questions decide most cases.

Verification checklist

Constraints and trade-offs

  • Animate-then-remove keeps nodes briefly out of sync with application state.
  • display: none hiding keeps nodes in the DOM, which matters for very large lists.
  • View transitions snapshot the whole page and block interaction for their duration.
  • Automatic view transition names are recent; manual names need uniqueness.
  • Focus inside a removed element is lost; move focus before the exit starts.

Frequently asked questions

Can CSS animate an element after it is removed from the DOM?

No. A removed element has no style to animate. Keep it present during the exit, hide it with allow-discrete, or animate a snapshot with a view transition.

What is the simplest exit animation without JavaScript?

Toggle display: none with transition-behavior: allow-discrete on display and transitions on opacity or transform, so the element stays rendered until the exit finishes.

How do I make remaining list items move smoothly after a removal?

Wrap the removal in document.startViewTransition and give items unique view transition names, or use the FLIP technique on the remaining items.

Where should focus go when a focused element exits?

Move it to a logical neighbour or the container before starting the exit, so keyboard users are not left on a disappearing element.