Halloween DealsAmazon USHalloween week deals: useful picks before month-endAmazon US: current deals, useful picks and tech finds.Check DealsClean PCRecommendedOne scan can reveal what keeps slowing WindowsLook for cleanup and repair opportunities.Run ScanHalloween DealsAmazon USOctober picks are moving - compare before checkoutAmazon US: current deals, useful picks and tech finds.See Picks×
FREEDOM251

Freedom251 › Blog › accessibility

Why React Dialog Exit Animations Get Cut Off—and How to Fix Them

By Freedom251 Team · · 11 min read

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

React dialog exit animations are usually cut off because the dialog is unmounted as soon as open becomes false. Once the content and overlay DOM nodes are gone, CSS has nothing to animate and a JavaScript animation library cannot observe a leaving child. Keep the dialog’s lifecycle boundary mounted, then let a presence or transition manager delay final removal until the close animation finishes.

What actually cuts off the closing animation?

React does not animate an unmount by itself. A render such as {open && <Dialog />} removes the entire dialog from the React tree during the render that changes open to false. The browser then removes the corresponding DOM nodes before a closing transition or keyframe can be seen.

As an Amazon Associate I earn from qualifying purchases.

The same problem appears one level higher. If an ancestor containing the dialog is conditional, the ancestor can disappear even when the dialog component has its own close state. An animation library has the same requirement in a different form: its exit manager must remain mounted long enough to detect that a child has left. Motion documents that AnimatePresence observes direct children removed from the React tree; if the AnimatePresence boundary is removed at the same time, it has no opportunity to run the child’s exit animation (Motion AnimatePresence documentation).

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Dialog primitives add another lifecycle layer. Radix Dialog ties its portal, overlay and content presence to the open state, and exposes forceMount when an application needs an animation library to control when those parts are removed (Radix Dialog source; Radix Dialog documentation). The visual animation and the dialog’s focus, overlay and accessibility behavior therefore have to be coordinated rather than solved by hiding a still-interactive element.

Diagnose the problem before changing code

  1. Find the first conditional. Search for open &&, a ternary that returns null, or an ancestor that is rendered only while the dialog is open. That is the node being removed immediately.
  2. Identify the exit owner. Decide whether Radix Presence, a CSS animation, Motion’s AnimatePresence, React Transition Group, or another manager is responsible for delaying removal. Two owners fighting over the same node often produce an abrupt close.
  3. Inspect the DOM during close. In browser developer tools, trigger close and watch whether the content and overlay remain in the DOM. If they vanish in the same frame as the state change, no animation rule can fix the timing.
  4. Check the rendered state. Radix exposes data-state='open' and data-state='closed' on relevant parts. Confirm that the closed selector is present and that its animation name is not overridden.
  5. Check direct-child placement. For Motion, the element that receives exit must be a direct child of AnimatePresence. A fragment or wrapper that is removed instead can hide the change from the boundary.
  6. Check keys and refs. Dynamically replaced children need stable, distinct keys. In Motion popLayout mode, a custom immediate child must forward a ref to the DOM element that is being taken out of layout (Motion AnimatePresence documentation).
  7. Check focus while waiting. A fix that leaves a closed dialog tabbable or traps focus behind an invisible overlay is not a correct fix. Radix documents modal focus trapping, announcements and focus restoration as part of its dialog behavior (Radix Dialog documentation).

Choose one component to control final removal

The implementation becomes predictable when one lifecycle manager owns the decision about when a leaving node is finally unmounted.

Approach Animation mechanism Who delays removal? Integration details
Radix Dialog with CSS keyframes CSS @keyframes selected from Radix state attributes Radix Presence observes the open/closed lifecycle Keep the Radix parts in place; verify the closed animation for the installed version
Radix Dialog with a JavaScript library JavaScript-controlled opacity, transforms or variants The animation boundary, with Radix parts mounted as needed Use forceMount on supported parts and let the animation finish before unmounting
Motion AnimatePresence Motion’s initial, animate and exit values AnimatePresence retains direct children during exit The boundary must stay mounted; use stable keys and forward refs in popLayout mode
React Transition Group CSS or JavaScript callbacks driven by transition states A transition component manages entering, leaving and unmount timing Use the API for the installed release; the project notes that v2 and later are not backward-compatible with v1

Do not treat a CSS transition declaration as a lifecycle manager. A transition interpolates between two styles only while the element exists. A keyframe animation can describe a complete close sequence, but the element still has to remain mounted until that sequence is complete.

Fix a Radix Dialog using CSS keyframes

Radix is the simplest route when the dialog can use CSS for its open and close visuals. Keep the root rendered and control its open state with the documented props:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import * as Dialog from '@radix-ui/react-dialog';

export function SettingsDialog({ open, setOpen }) {
  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger>Settings</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className='DialogOverlay' />
        <Dialog.Content className='DialogContent'>
          <Dialog.Title>Settings</Dialog.Title>
          <Dialog.Close>Close</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Use the state attributes on the overlay and content to select separate keyframes. The exact presence behavior can vary with the Radix version you have installed, so verify the version-specific animation guidance alongside the component API.

.DialogOverlay[data-state='open'] {
  animation: overlayIn 180ms ease-out;
}

.DialogOverlay[data-state='closed'] {
  animation: overlayOut 140ms ease-in forwards;
}

.DialogContent[data-state='open'] {
  animation: dialogIn 220ms cubic-bezier(.16, 1, .3, 1);
}

.DialogContent[data-state='closed'] {
  animation: dialogOut 160ms ease-in forwards;
}

@keyframes overlayIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes overlayOut {
  from { opacity: 1; }
  to { opacity: 0; }
}

@keyframes dialogIn {
  from { opacity: 0; transform: translateY(8px) scale(.98); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}

@keyframes dialogOut {
  from { opacity: 1; transform: translateY(0) scale(1); }
  to { opacity: 0; transform: translateY(8px) scale(.98); }
}

Do not add a second {open && ...} around Dialog.Portal, Dialog.Overlay or Dialog.Content if Radix is supposed to observe their closed state. That conditional removes the node before Radix Presence can retain it. Also avoid setting display: none or pointer-events rules that make the closing element interactive while it is leaving; the dialog primitive should continue to manage focus and modality.

Give a JavaScript animation library control with Radix

Use this path when the animation needs JavaScript-driven variants, sequencing or callbacks. Radix documents forceMount for cases where an animation library needs more control over mounting and unmounting (Radix Dialog documentation).

Mount the Radix parts with forceMount, place the visual element under the animation boundary, and make the open boolean the animation boundary’s condition. The exact wrapper arrangement depends on the library, but the ownership rule is constant: Radix must not remove the node before the animation manager has finished.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
<Dialog.Root open={open} onOpenChange={setOpen}>
  <Dialog.Portal forceMount>
    <AnimatePresence>
      {open ? (
        <Dialog.Overlay forceMount asChild>
          <motion.div
            key='overlay'
            className='DialogOverlay'
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
          />
        </Dialog.Overlay>
      ) : null}
    </AnimatePresence>
    <AnimatePresence>
      {open ? (
        <Dialog.Content forceMount asChild>
          <motion.div
            key='content'
            className='DialogContent'
            initial={{ opacity: 0, y: 8, scale: .98 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 8, scale: .98 }}
          >
            ...
          </motion.div>
        </Dialog.Content>
      ) : null}
    </AnimatePresence>
  </Dialog.Portal>
</Dialog.Root>

Before using this exact composition, check the installed Radix and animation-library APIs: asChild changes which element receives props and refs, and the overlay and content may need to be grouped under one boundary if they must leave together. The important checks are that every required Radix part supports forceMount, the animated DOM node receives the dialog props and ref, and the boundary remains mounted while its direct child exits.

Fix Motion exit animations with a permanent AnimatePresence boundary

Motion detects a direct child leaving the React tree and keeps it around for its exit animation. The common mistake is to conditionally render the boundary itself:

{open && (
  <AnimatePresence>
    <motion.div exit={{ opacity: 0 }} />
  </AnimatePresence>
)}

When open changes, both the boundary and its child disappear. Keep AnimatePresence unconditional and put the condition inside it:

<AnimatePresence>
  {open && (
    <motion.div
      key='dialog'
      role='dialog'
      initial={{ opacity: 0, y: 12 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: 12 }}
    >
      Dialog content
    </motion.div>
  )}
</AnimatePresence>

If a dialog can be replaced by another dialog or panel, give each direct child a stable key that changes when the identity changes. Without distinct keys, Motion may treat the new content as an update rather than a leaving child. Keep the key stable for the lifetime of one dialog instance; do not generate a new random key on every render.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When using popLayout, a custom component immediately below AnimatePresence must forward its ref to the actual DOM element Motion should remove from layout. Otherwise Motion cannot apply the layout-pop operation correctly (Motion AnimatePresence documentation). This ref requirement is separate from the basic exit problem, but it commonly appears when a dialog is wrapped in a design-system component.

Use React Transition Group when it owns the transition state

React Transition Group provides components that manage mounting and unmounting states over time for animation (React Transition Group repository). A typical pattern keeps the transition component mounted and lets its in prop describe whether the dialog is present:

import { CSSTransition } from 'react-transition-group';

<CSSTransition
  in={open}
  timeout={180}
  classNames='dialog'
  unmountOnExit
>
  <div role='dialog' aria-modal='true'>
    Dialog content
  </div>
</CSSTransition>

unmountOnExit should be owned by the transition component, not by a surrounding open && expression. The timeout and CSS animation duration must describe the same interval; if the transition manager is told to finish sooner than the CSS animation, removal still looks clipped. If JavaScript callbacks determine completion, call the library’s completion hook only after the visual animation has ended.

Confirm the API against the version installed in your project. The project states that its v2-and-later API is not backward-compatible with v1 and that the v1 codebase is no longer updated (React Transition Group repository).

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Preserve dialog accessibility during the visual exit

A dialog is more than a box that fades out. Radix’s documented behavior includes modal focus trapping, screen-reader announcements, an overlay and focus restoration (Radix Dialog documentation). A visual workaround must not leave a closed dialog available to keyboard or assistive-technology users.

  • Keep focus ownership explicit. When close starts, focus should follow the dialog primitive’s restoration behavior. Do not move focus to an element hidden by the closing animation.
  • Do not leave an invisible modal backdrop active. A transparent overlay that still intercepts clicks or traps focus after the user considers the dialog closed creates a second, accessibility-related bug.
  • Animate the semantic parts together. If content fades out but the overlay remains modal, users may be unable to interact with the page during the remainder of the animation. If the overlay vanishes first, the dialog can appear detached from its modality. Coordinate both timelines deliberately.
  • Keep labels and roles on the actual animated element. Wrapping a Radix part with a JavaScript animation component can drop refs or props unless the wrapper forwards them.
  • Respect reduced motion. Provide a reduced-motion rule that shortens or removes visual movement while retaining the same mount and focus lifecycle. Do not replace lifecycle management with a permanently hidden but interactive node.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Common failed fixes and the precise repair

Conditioning the whole dialog on open

Symptom: The dialog disappears on the same frame as the close click.

Repair: Keep the root and the chosen animation boundary mounted. Put the open condition inside the boundary, or let the dialog primitive’s Presence implementation observe the state.

Putting AnimatePresence inside the conditional

Symptom: The exit prop is present, but no exit animation runs.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Repair: Render AnimatePresence unconditionally and make the animated dialog its direct conditional child.

Using a CSS transition without a retained node

Symptom: Changing opacity or transform appears to work when opening but not closing.

Repair: Delay unmount through Radix Presence, Motion, React Transition Group or another transition manager. A CSS transition cannot animate a node that has already been removed.

Applying forceMount and never handing removal back

Symptom: The animation plays, but the closed dialog remains in the DOM and may interfere with focus or pointer input.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Repair: Treat forceMount as permission for your animation layer to own removal, not as a permanent hidden state. Ensure the animation boundary actually unmounts the leaving part after completion.

Animating a wrapper while the primitive removes its child

Symptom: The wrapper remains briefly, but the dialog content vanishes inside it.

Repair: Put the animation on the DOM element whose lifecycle is being retained, or use the primitive’s composition API so the animated element receives the primitive’s props and ref.

Mismatched durations

Symptom: The last few frames are clipped, or the dialog stays mounted noticeably longer than the animation.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Repair: Use one source of truth for duration. Match Motion’s transition duration, a transition component’s timeout and the CSS keyframe duration, including any delay. If separate overlay and content animations have different lengths, the owner must wait for the longer one.

Changing the key on every render

Symptom: The dialog repeatedly re-enters or never completes a clean exit.

Repair: Use a stable key for one dialog instance and change it only when the displayed item genuinely changes.

A reliable verification procedure

  1. Open the dialog and confirm the expected focus target, title announcement and overlay.
  2. Start closing it with the close button, Escape and any outside-click behavior your design permits. All paths should use the same open-state transition.
  3. Watch the content and overlay in developer tools. They should remain mounted for the intended exit interval and then disappear.
  4. Use a slow-motion setting or temporarily lengthen the animation to verify that the closed state is actually rendered.
  5. Tab repeatedly while closing. Focus must not escape into an invisible modal or become trapped after the dialog is gone.
  6. After removal, inspect the document for stale overlays, duplicate dialog IDs and nodes that still receive pointer events.
  7. Test a reduced-motion preference and confirm that shortening the visual effect does not bypass focus restoration or final unmounting.
  8. Test rapid open-close-open sequences. The lifecycle owner should cancel or reverse animations cleanly rather than leaving two overlays or a permanently mounted closed dialog.

Where React ViewTransition fits

React also documents a ViewTransition reference for its newer view-transition integration (React ViewTransition reference). That API does not remove the fundamental lifecycle requirement for a dialog: the element or a framework-managed boundary must remain available while the transition is captured. Treat it as an additional transition mechanism under the conditions documented by React, not as a guarantee that an ordinary conditional unmount will animate.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The practical rule

Make the close operation a state change, not an immediate deletion. Keep the dialog primitive or animation boundary mounted, let exactly one lifecycle manager observe the leaving child, and remove the DOM only after the exit lifecycle completes. For Radix CSS animations, use its open and closed state attributes and verify the installed version’s Presence behavior. For JavaScript animation, use forceMount where Radix supports it and let the animation layer own final removal. For Motion, keep AnimatePresence outside the conditional and use stable direct-child keys. For React Transition Group, let the transition component manage unmountOnExit and match its timeout to the visual effect. Throughout, preserve focus, modality and screen-reader behavior until the dialog is genuinely closed.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

accessibilityCSS AnimationsDIALOGSexit animationsMotionRadix UIReactReact Transition Group

Leave a Reply

Your email address will not be published. Required fields are marked *

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Recommended PC Tool
Recommended PC Tool
Outdated Drivers Are Slowing You DownFree scan - exact matches
Windows Errors? Fix Them Before They SpreadFree repair scan

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.