Exit animations on React dialogs are a commonly reported source of confusion in frontend development. The core issue is deceptively simple: React’s conditional rendering model removes DOM nodes synchronously, which means CSS transitions and JavaScript animations targeting the exit state never get a chance to run. This guide evaluates five distinct approaches to solving the problem, each with working code, concrete trade-offs, and clear guidance on when to reach for which tool.
Table of Contents
Prerequisites
- Node.js ≥ 18 LTS and npm (or yarn)
- React 18 or later (the examples were tested with React 19)
- A bundler that supports CSS imports (Vite, CRA, or Next.js)
- For Approach 2:
npm install motion - For Approach 3:
npm install @radix-ui/react-dialog - For Approaches 4 and 5: a browser with the relevant feature support (see each section’s trade-offs)
Why React Dialog Exit Animations Break
The Conditional Rendering Trap
A common pattern for showing and hiding a dialog in React looks like this:
{isOpen && <Dialog />}
Conditional rendering works perfectly for components that do not need animated exits. The problem surfaces the moment a developer adds a CSS transition or animation intended to play when the dialog closes.
When isOpen flips from true to false, React reconciles the virtual DOM, determines the <Dialog /> component should no longer exist, and removes its real DOM nodes synchronously. The browser never gets a frame where the exit CSS class or transition target state is active on a live DOM node. The element is simply gone.
This stands in stark contrast to vanilla JavaScript or jQuery workflows, where the DOM node persists indefinitely. A developer would add a class like .closing, wait for the browser’s transitionend event to fire, and only then call element.remove(). React’s declarative model trades away manually controlling the lifecycle for simplicity, but exit animations are where the trade-off bites.
Here is the broken pattern made concrete:
import './dialog.css';
export default function BrokenDialog({ open, onClose }) {
return (
<>
{open && (
<div className="dialog-overlay" onClick={onClose}>
<div className="dialog-content">
<p>This dialog's exit animation will never play.</p>
<button onClick={onClose}>Close</button>
</div>
</div>
)}
</>
);
}
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity 300ms ease;
}
.dialog-overlay.closing {
opacity: 0;
}
.dialog-content {
background: white;
padding: 2rem;
border-radius: 8px;
transform: scale(1);
transition: transform 300ms ease;
}
.dialog-overlay.closing .dialog-content {
transform: scale(0.95);
}
The .closing class never gets applied to a live DOM node. The moment open becomes false, React unmounts the entire subtree. The CSS transitions defined for the exit state are inert.
What Needs to Happen Instead
The DOM node must remain mounted long enough for the exit animation to complete, then be removed. Every solution to this problem falls into one of two fundamental strategies: keep the node mounted and hide it visually (using visibility, display, or attribute toggles), or delay the unmount by holding the component in the DOM for the duration of the animation before allowing React to remove it.
Approach 1: Manual Delayed Unmount with State
How It Works
The delayed unmount pattern separates rendering intent from DOM presence using two pieces of state. The first, isOpen, represents whether the dialog should appear open. The second, shouldRender, controls whether the component is in the DOM at all.
On open, both values are set to true. On close, isOpen is set to false, which triggers CSS exit classes or transitions. An event listener on transitionend or animationend (or a fallback timeout) then sets shouldRender to false, removing the node.
import { useState, useEffect, useRef } from 'react';
export function useDelayedUnmount(isOpen, delayMs = 300) {
const [shouldRender, setShouldRender] = useState(isOpen);
const isOpenRef = useRef(isOpen);
useEffect(() => {
isOpenRef.current = isOpen;
if (isOpen) {
setShouldRender(true);
} else {
const timer = setTimeout(() => {
if (!isOpenRef.current) {
setShouldRender(false);
}
}, delayMs);
return () => clearTimeout(timer);
}
}, [isOpen, delayMs]);
return shouldRender;
}
import { useDelayedUnmount } from './useDelayedUnmount';
export default function Dialog({ isOpen, onClose }) {
const shouldRender = useDelayedUnmount(isOpen, 300);
if (!shouldRender) return null;
return (
<div
className={`dialog-overlay ${isOpen ? 'open' : 'closing'}`}
onClick={onClose}
>
<div
className="dialog-content"
onClick={(e) => e.stopPropagation()}
>
<p>Animated dialog with manual delayed unmount.</p>
<button onClick={onClose}>Close</button>
</div>
</div>
);
}
The CSS remains identical to the earlier example, but now the component applies the .closing class to a node that still exists in the DOM. After 300ms, the timeout fires and the node is removed.
SSR note: In SSR environments (Next.js, Remix), ensure the isOpen initial value matches server-rendered state to avoid hydration mismatches.
Trade-offs
This approach carries zero library overhead and gives full control over timing. However, it is fragile. If the animation duration in CSS changes without updating the hook’s delayMs, the hook will unmount the node too early or leave it lingering invisibly. Using onTransitionEnd instead of a timeout fixes the synchronization issue but introduces its own pitfalls: transitionend fires once per transitioned CSS property, so if multiple properties transition simultaneously, cleanup logic must guard against multiple invocations. Additionally, if any code path applies display: none before the transition ends, transitionend will not fire. Rapid open/close toggling requires careful cleanup of timers to avoid stale state.
Approach 2: Motion’s AnimatePresence
How AnimatePresence Solves Unmount
Motion (formerly Framer Motion, now published as the motion npm package) provides AnimatePresence, a component specifically designed to intercept React’s unmount cycle. Install with npm install motion; the import path is motion/react. Users on framer-motion v10 should use import { AnimatePresence, motion } from 'framer-motion' instead.
When a child wrapped in AnimatePresence is conditionally removed, Motion holds the node in the DOM, plays the exit animation defined on the motion.* element, and removes the node only after the animation completes.
The mode="wait" prop ensures that an exiting element finishes its animation before a new entering element mounts, which is useful when swapping dialog content. Each conditionally rendered child must have a unique key prop so Motion can track which nodes are entering and exiting.
import { AnimatePresence, motion } from 'motion/react';
export default function Dialog({ isOpen, onClose }) {
return (
<AnimatePresence mode="wait">
{isOpen && (
<motion.div
key="dialog-overlay"
className="dialog-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
onClick={onClose}
>
<motion.div
className="dialog-content"
initial={{ scale: 0.95 }}
animate={{ scale: 1 }}
exit={{ scale: 0.95 }}
transition={{ duration: 0.3 }}
onClick={(e) => e.stopPropagation()}
>
<p>Dialog with Motion AnimatePresence.</p>
<button onClick={onClose}>Close</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
When AnimatePresence Fails Silently
The most common failure mode is a missing key prop on the conditional child. Without it, Motion cannot distinguish between an element that should exit and one that should update. Nested conditional rendering (where the AnimatePresence wraps a parent that itself conditionally renders the animated element) also breaks exit detection. The rule is straightforward: AnimatePresence must be the direct parent of the conditionally rendered motion.* element.
Trade-offs
Per Motion’s , the declarative motion component cannot tree-shake below about 34 kB gzipped. Swapping in the m component with LazyMotion and domAnimation brings that down to roughly 4.6 kB for the initial render plus about 15 kB for the animation features (including exit animations). Either way you also get spring physics, keyframe sequencing, and (with domMax) layout animations that go well beyond dialog transitions. If the dialog is the only animated element in the application, that cost is hard to justify. For applications already using Motion for page transitions, gestures, or layout animations, the marginal cost of dialog exit animations is close to zero.
Approach 3: Radix UI Dialog with CSS Animations
How Radix Handles Mounting
Radix UI’s Dialog parts solve the unmount problem without a JavaScript animation library. When the dialog opens, the content and overlay receive data-state="open". When it begins closing, Radix sets data-state="closed" and suspends the unmount while your CSS animation plays out, as described in the . Once the animation finishes, the nodes are removed.
The forceMount prop on Portal, Overlay and Content hands control of mounting and unmounting to you. You need it when a JavaScript animation library, rather than CSS, drives the exit.
import * as Dialog from '@radix-ui/react-dialog';
import './radix-dialog.css';
export default function AnimatedDialog({ isOpen, onOpenChange }) {
return (
<Dialog.Root open={isOpen} onOpenChange={onOpenChange}>
<Dialog.Trigger asChild>
<button>Open Dialog</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="radix-overlay" />
<Dialog.Content className="radix-content">
<Dialog.Title>Radix Animated Dialog</Dialog.Title>
<Dialog.Description>
Exit animation handled via data-state attributes.
</Dialog.Description>
<Dialog.Close asChild>
<button>Close</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
.radix-overlay[data-state='open'] {
animation: fadeIn 200ms ease;
}
.radix-overlay[data-state='closed'] {
animation: fadeOut 200ms ease;
}
.radix-content[data-state='open'] {
animation: scaleIn 200ms ease;
}
.radix-content[data-state='closed'] {
animation: scaleOut 200ms ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes scaleIn { from { transform: scale(0.95); } to { transform: scale(1); } }
@keyframes scaleOut { from { transform: scale(1); } to { transform: scale(0.95); } }
Composing Radix + Motion
For spring physics or complex choreography, combine Radix’s forceMount prop with Motion’s AnimatePresence. Radix still handles focus trapping, ARIA attributes, and Escape key handling, while Motion owns the mount and exit timing.
Render Dialog.Portal conditionally inside AnimatePresence, and pass forceMount to the portal, overlay and content. Use asChild so Radix applies its props to the motion.div elements instead of rendering extra wrapper nodes. AnimatePresence must be the direct parent of the conditionally rendered element, and isOpen comes from the same state you pass to Dialog.Root:
import { AnimatePresence, motion } from 'motion/react';
import * as Dialog from '@radix-ui/react-dialog';
export default function RadixMotionDialog({ isOpen, onOpenChange }) {
return (
<Dialog.Root open={isOpen} onOpenChange={onOpenChange}>
<Dialog.Trigger asChild>
<button>Open Dialog</button>
</Dialog.Trigger>
<AnimatePresence>
{isOpen && (
<Dialog.Portal forceMount>
<Dialog.Overlay asChild forceMount>
<motion.div
className="radix-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
</Dialog.Overlay>
<Dialog.Content asChild forceMount>
<motion.div
className="radix-content"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
>
<Dialog.Title>Radix + Motion Dialog</Dialog.Title>
<Dialog.Description>
Exit animation driven by Motion, accessibility handled by Radix.
</Dialog.Description>
<Dialog.Close asChild>
<button>Close</button>
</Dialog.Close>
</motion.div>
</Dialog.Content>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
);
}
Because the portal only renders while isOpen is true, closed dialog content does not stay in the DOM, so nothing sensitive lingers there. If you do need the content mounted while closed, move forceMount and the open check onto the individual parts.
Trade-offs
Radix provides accessibility out of the box: focus trapping, aria-* attributes, and Escape key handling are all built in. Check the current size of @radix-ui/react-dialog on bundlephobia.com before you decide, since it also pulls in several shared Radix packages. The downside is coupling to Radix’s compound-component API, which can make customizing the overlay stacking model painful — for example, controlling z-index requires wrapping the portal in a custom container because Radix’s default portal appends to document.body.
Approach 4: Native <dialog> Element with CSS Transitions
showModal(), close(), and the ::backdrop Pseudo-element
The HTML <dialog> element sidesteps the unmount problem entirely. The element is always present in the DOM; showModal() and close() toggle the open attribute and move the element to the browser’s top layer. Since the node is never removed, standard CSS transitions on opacity, transform, and the ::backdrop pseudo-element work without any React-specific workaround.
Note that close() fires synchronously, restoring focus and dismissing the backdrop immediately. The CSS exit animation is purely visual at this point — screen readers will announce the dialog as closed while the animation is still playing. If you need to delay programmatic close until the animation completes, listen for animationend before calling close().
import { useRef, useEffect } from 'react';
import './native-dialog.css';
export default function NativeDialog({ isOpen, onClose }) {
const dialogRef = useRef(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
if (!dialog.open) {
try {
dialog.showModal();
} catch (err) {
console.error('[NativeDialog] showModal() failed:', err);
}
}
} else {
if (dialog.open) {
dialog.close();
}
}
}, [isOpen]);
return (
<dialog ref={dialogRef} className="native-dialog" onClose={onClose}>
<p>Native dialog with CSS transitions.</p>
<button onClick={onClose}>Close</button>
</dialog>
);
}
.native-dialog {
opacity: 1;
transform: scale(1);
transition: opacity 300ms ease, transform 300ms ease, display 300ms, overlay 300ms;
transition-behavior: allow-discrete;
}
.native-dialog:not([open]) {
opacity: 0;
transform: scale(0.95);
}
@starting-style {
.native-dialog[open] {
opacity: 0;
transform: scale(0.95);
}
}
.native-dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
opacity: 1;
transition: opacity 300ms ease, display 300ms, overlay 300ms;
transition-behavior: allow-discrete;
}
.native-dialog:not([open])::backdrop {
opacity: 0;
}
@starting-style {
.native-dialog[open]::backdrop {
opacity: 0;
}
}
The @starting-style rule (supported across major browsers since 2024; check current status at caniuse.com/css-at-rule-starting-style) enables entry transitions on elements that go from display: none to display: block. The transition-behavior: allow-discrete declaration is required for animating display and overlay, which are discrete properties. overlay is a CSS property that controls whether an element participates in the browser’s top layer; animating it with allow-discrete ensures the dialog leaves the top layer only after the transition completes.
Trade-offs
Zero JavaScript animation overhead here. The browser provides native focus trapping and top-layer stacking. @starting-style and transition-behavior: allow-discrete are supported in current Chrome, Firefox (129+) and Safari (17.5+), and @starting-style is , so older browsers are the only gap; they show the dialog without the animation. Styling ::backdrop transitions remains constrained. The allow-discrete requirement is a property most developers haven’t encountered yet, which can slow down code review and onboarding.
Approach 5: View Transitions API
document.startViewTransition() for Dialog Toggle
The View Transitions API offers a browser-native approach where the developer wraps the state change in document.startViewTransition(). The browser captures a snapshot of the current state, applies the DOM update, captures the new state, and cross-fades between them automatically. This means the dialog node can be conditionally rendered, and the browser handles the visual transition between the “dialog visible” and “dialog gone” states.
import { flushSync } from 'react-dom';
export function handleClose(setOpen) {
if (document.startViewTransition) {
const transition = document.startViewTransition(() => {
flushSync(() => setOpen(false));
});
transition.finished.catch((err) => {
console.error('[handleClose] View transition failed:', err);
setOpen(false);
});
} else {
setOpen(false);
}
}
The flushSync call is required because React’s automatic batching would otherwise defer the state update, causing the snapshot to capture the wrong state. Caution: flushSync opts out of concurrent rendering; avoid using it in components that rely on useTransition or Suspense.
Trade-offs
This approach requires no extra dependencies and produces hardware-composited cross-fade transitions with minimal code. However, choreography control is limited compared to Motion’s spring and keyframe system, and the dialog still unmounts as soon as the transition ends. Same-document view transitions are supported in Chrome, Safari 18 and Firefox 144, and are since October 2025. The code above falls back to an instant state change when document.startViewTransition is missing.
Comparison Table: Choosing the Right Approach
| Criteria | Manual Delayed Unmount | Motion AnimatePresence | Radix UI Dialog | Native <dialog> + CSS | View Transitions API |
|---|---|---|---|---|---|
| Bundle overhead | 0 kB | ~6-11 kB gzipped (tree-shaken) | ~5-8 kB gzipped | 0 kB | 0 kB |
| DOM mounting strategy | Delayed unmount | Delayed unmount | Attribute lifecycle / forceMount |
Always mounted (ref-controlled) | Conditional OK (browser snapshots) |
| Heavy content / SSR | Poor (unmounts) | Poor (unmounts) | Good (forceMount keeps content mounted) |
Good (always in DOM) | Poor (unmounts) |
| Exit animation complexity | Low (CSS only) | High (spring, keyframes, layout) | Medium (CSS + composable with Motion) | Medium (CSS, @starting-style) |
Low (cross-fade, named transitions) |
| Accessibility built-in | No (DIY) | No (DIY) | Yes | Yes (partial)* | No (DIY) |
| Browser support risk | None | None | None | Older browsers skip animation | Newer (Baseline 2025) |
* Native <dialog> provides focus trapping and top-layer promotion automatically; you must add ARIA labeling (aria-labelledby, aria-describedby) and a close button yourself.
If your bundle budget is tight and you only need simple fades, the native <dialog> or View Transitions columns are the strongest fit. Teams building design systems with complex, physics-based animations across many components will recoup Motion’s bundle cost quickly. For teams that want accessibility guarantees without building focus trapping and ARIA management from scratch, Radix occupies the middle ground.
Practical Decision Checklist
- For simple fades or slides, native
<dialog>with CSS transitions or the View Transitions API will handle the job without extra dependencies. - Spring physics or complex choreographed exits call for Motion’s AnimatePresence.
- If you need an accessible compound component out of the box, use Radix UI Dialog.
- The manual delayed unmount hook gives you zero dependencies and full control over every detail, at the cost of fragility.
- Already using Motion but need accessibility guarantees? Compose Radix
forceMountwith AnimatePresence.
Wrapping Up
The root cause of broken React dialog exit animations is always the same: conditional rendering triggers synchronous unmount, and the DOM node disappears before any exit transition can begin. There is no single correct solution. The right choice depends on bundle budget, animation complexity, accessibility requirements, and browser-support tolerance. For further reference, consult the Motion AnimatePresence documentation, the Radix Dialog primitive docs, MDN’s <dialog> element reference, and MDN’s View Transitions API guide.