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.
Step-by-step resolution
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,scaleandtranslate; 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.
Verification checklist
Constraints and trade-offs
- Animate-then-remove keeps nodes briefly out of sync with application state.
display: nonehiding 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.
Related
- CSS @starting-style & Entry Effects — the parent topic
- @starting-style for List Item Insertion — the entry half of list motion
- Animation State Across Framework Re-renders — exits in component frameworks