TIL

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:

  1. React re-renders the component
  2. The conditional {isOpen && ...} evaluates to false
  3. React removes the modal from the DOM immediately
  4. The .closed class 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:

  1. User clicks "Close" → Start exit animation
  2. User quickly clicks "Open" again (while animating!)
  3. 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 defined
ANIMATION_OUT: 'unmountSuspended', // Start exit animation
},
unmountSuspended: {
MOUNT: 'mounted', // User reopened! Interrupt exit
ANIMATION_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:

  1. The event came from the correct element (not a child)
  2. 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:

  1. data-state attribute - Instead of using className that the child controls, Presence injects a data-state attribute. This ensures the CSS selector changes the moment present changes, before the state machine decides what to do.

  2. cloneElement pattern - Presence clones the child to inject the ref and data-state. This is how the real Radix implementation works.

  3. animationstart listener - Captures the animation name when it actually starts, ensuring we track the correct animation for comparison.

  4. isPresent includes both states - The child stays in the DOM during both mounted AND unmountSuspended states, but data-state reflects the actual present prop.

Now click "Open" and "Close"—the exit animation plays smoothly. The key differences from the broken version:

  1. Presence wraps the conditional render – it controls when the element actually leaves the DOM
  2. isPresent drives the CSS class – the child knows whether it's entering or exiting
  3. 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 Presence to animate its overlay and content
  • Popover uses Presence for smooth open/close transitions
  • Tooltip uses Presence to fade in/out on hover
  • Collapsible uses Presence for 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.

References

If you enjoyed this article, please click the buttons below to share it with more people. Your support means a lot to me as a writer.
Share on X
Buy me a coffee