How it works
Three mechanisms behind one attribute: keyframes for Radix, transitions for Base UI, and native open state for dialog and popover.
Two mechanisms sit behind one attribute, and you never pick between them. The host's own state attributes decide, because they are what is actually in the DOM when the component opens. Four hosts, and the split is uneven: only Radix needs keyframes.
Hosts and mechanisms
Keyframes1 host
Radix UI
data-state="open" | "closed"Presence holds a closing node until animationend, so a keyframe can finish.
Transitions3 hosts
Base UI
data-starting-style / data-ending-stylePreferred where available: an interrupted transition resolves, where a keyframe restarts.
Native dialog and popover
[open] / :popover-openNo library involved. display and overlay transition under allow-discrete.
Plain elements
[hidden]The floor. Any element that toggles hidden, with no component library adopted.
Why the mechanism differs
An exit animation needs the element to still exist while it plays. Radix keeps it mounted and announces the closed state, so keyframes work. Base UI and the platform give you a start and an end state to interpolate between, so transitions work and are the better tool: they interrupt cleanly when someone opens and closes the same thing quickly, where a keyframe restarts.
Do not fight it
Never add Framer Motion or Motion to an element carrying data-motion. Both write transform, so the two take turns overwriting translate and scale and the result reads as a stutter rather than as a bug. Pick one owner for a given element.
{/* Wrong: two owners for one transform */}
<motion.div animate={{ scale: 1 }} data-motion="scale-fade" />
{/* Right: the host signals state, easeful animates it */}
<Dialog.Content data-motion="scale-fade" />