Radix UI - Present
- 文章發表於
- ...
Introduction
Have you ever tried to add a fade-out animation to a component in React, only to find it disappears instantly? React's declarative nature creates an unexpected challenge: when a component's condition becomes false, React removes it immediately, no time for graceful exits.
This creates a fundamental tension. CSS animations need the element to exist in the DOM to run, but React's job is to keep the DOM in sync with state. When isOpen becomes false, React does exactly what it should: removes the element. The animation never gets a chance to play.
Radix UI's Presence primitive solves this elegantly. It acts as a gatekeeper between React's state and the actual DOM, delaying unmount until animations complete. Let's understand how.
The Problem
Consider this common scenario: a modal that should fade in when opening and fade out when closing. A natural first attempt is to toggle a CSS class based on state:
import { useState } from 'react'; import './index.css' function App() { const [isOpen, setIsOpen] = useState(false); return ( <div> <button onClick={() => setIsOpen(!isOpen)}> {isOpen ? 'Close' : 'Open'} </button> {isOpen && ( <div className={`modal ${isOpen ? 'open' : 'closed'}`}> Hello World! </div> )} </div> ); } export default App
Click "Open" and you'll see a smooth fade-in. Now click "Close"—the modal vanishes instantly. The .closed class with fadeOut animation is never applied. Why?
The Race Against Unmount
When isOpen becomes false, React's reconciliation process kicks in:
- React re-renders the component
- The conditional
{isOpen && ...}evaluates tofalse - React removes the modal from the DOM immediately
- The
.closedclass is never applied—the element is already gone
Look at line 12: the modal only exists inside {isOpen && (...)}. This means whenever the modal renders, isOpen is guaranteed to be true. The ternary isOpen ? 'open' : 'closed' will always pick 'open'—the 'closed' branch is unreachable code. The instant isOpen flips to false, React unmounts the element before any class change can occur.
This is the core tension: React optimizes for keeping the DOM in sync with state, but animations need time to complete before removal.
What We Need
We need something that intercepts React's unmount, holds the element in the DOM while the animation plays, listens for animation completion, and only then allows the actual removal. This is exactly what Presence does.
Understanding the Architecture
The Core Insight: Three States
The key insight is that "visible or not" is too simple. We need three states:
type State =| 'mounted' // Component is visible and interactive| 'unmountSuspended' // Exit animation playing, still in DOM| 'unmounted'; // Actually removed from DOM
The middle state is the breakthrough. It represents the liminal moment when the user has requested closure, but the element hasn't left yet.
Why Three States?
Consider this scenario without the middle state:
- User clicks "Close" → Start exit animation
- User quickly clicks "Open" again (while animating!)
- What should happen?
With only two states (mounted/unmounted), we'd have no way to know the element is currently animating out. We might:
- Restart the animation awkwardly
- Show a visual glitch
- Lose track of the animation entirely
The unmountSuspended state gives us a clear signal: "I'm on my way out, but I'm still here—and I can be interrupted."
The State Machine
The complete state machine looks like this:
const stateMachine = {mounted: {UNMOUNT: 'unmounted', // No animation definedANIMATION_OUT: 'unmountSuspended', // Start exit animation},unmountSuspended: {MOUNT: 'mounted', // User reopened! Interrupt exitANIMATION_END: 'unmounted', // Animation finished, now remove},unmounted: {MOUNT: 'mounted', // Fresh mount},};
Notice the dashed arrow from mounted directly to unmounted. This is the fast path: if no exit animation is defined in CSS, we skip the suspended state entirely. No point waiting for an animation that doesn't exist.
State Transitions Visualized:
Animation Detection: The Key Algorithm
Here's the clever part. How does Presence know whether an exit animation exists? It doesn't read your CSS files—it observes what actually happens in the DOM.
The algorithm compares animation names before and after the present prop changes:
const prevAnimationNameRef = useRef('none');const currentAnimationName = getComputedStyle(node).animationName;const isAnimating = prevAnimationNameRef.current !== currentAnimationName;
This comparison is the heart of the detection logic:
if (present) {send('MOUNT');} else if (currentAnimationName === 'none') {send('UNMOUNT'); // No animation defined at all} else if (isAnimating) {send('ANIMATION_OUT'); // Animation name changed → exit animation started!} else {send('UNMOUNT'); // Animation name unchanged → no exit animation}
Why compare animation names instead of checking if animation is defined? Because CSS might define an animation for the open state only. By comparing what changed, Presence correctly distinguishes between:
- "Has an enter animation only" → unmount immediately on close
- "Has both enter and exit animations" → wait for exit animation
- "Has no animations" → unmount immediately
Listening for Animation End
Once Presence detects an exit animation, it needs to know when it finishes. This is straightforward:
const handleAnimationEnd = (event) => {const currentAnimationName = getComputedStyle(node).animationName;const isCurrentAnimation = currentAnimationName.includes(event.animationName);if (event.target === node && isCurrentAnimation) {send('ANIMATION_END');}};node.addEventListener('animationend', handleAnimationEnd);
The animationend event fires when a CSS animation completes. Presence validates that:
- The event came from the correct element (not a child)
- The animation that ended is the current one (handles animation changes mid-flight)
Multiple Animations: "First Wins"
What if you have multiple animations?
.modal {animation: fadeOut 500ms, slideDown 200ms;}
Which animation does Presence wait for? Whichever finishes first.
Timeline:├─ 0ms: Both animations start├─ 200ms: slideDown finishes first│ ├─ animationend event fires│ ├─ Validates: "slideDown" in "fadeOut, slideDown" ✓│ └─ Unmounts immediately└─ 500ms: fadeOut never completes (component is gone!)
This is intentional. If you need both animations to complete, combine them into a single @keyframes rule or use the longer duration for both.
Implementation
Let's build a minimal Presence to see these concepts in action. The implementation uses cloneElement to inject a ref and data-state attribute into the child, matching how the real Radix implementation works.
import { useState } from 'react'; import { Presence } from './presence.js'; import './index.css' function App() { const [isOpen, setIsOpen] = useState(false); return ( <div> <button onClick={() => setIsOpen(!isOpen)}> {isOpen ? 'Close' : 'Open'} </button> <Presence present={isOpen}> <div className="modal"> Hello World! </div> </Presence> </div> ); } export default App
Key implementation details:
data-stateattribute - Instead of usingclassNamethat the child controls, Presence injects adata-stateattribute. This ensures the CSS selector changes the momentpresentchanges, before the state machine decides what to do.cloneElementpattern - Presence clones the child to inject the ref and data-state. This is how the real Radix implementation works.animationstartlistener - Captures the animation name when it actually starts, ensuring we track the correct animation for comparison.isPresentincludes both states - The child stays in the DOM during bothmountedANDunmountSuspendedstates, butdata-statereflects the actualpresentprop.
Now click "Open" and "Close"—the exit animation plays smoothly. The key differences from the broken version:
Presencewraps the conditional render – it controls when the element actually leaves the DOMisPresentdrives the CSS class – the child knows whether it's entering or exiting- State machine coordinates timing – the element stays in DOM during
unmountSuspended
Connection to Other Primitives
Presence doesn't exist in isolation. It's a building block that other Radix primitives compose:
- Dialog uses
Presenceto animate its overlay and content - Popover uses
Presencefor smooth open/close transitions - Tooltip uses
Presenceto fade in/out on hover - Collapsible uses
Presencefor expand/collapse animations
These components combine Presence with other primitives like Slot (for prop merging)and Composition (for managing groups of items). Each primitive handles one concern well, and composition creates sophisticated behavior.
