Capítulo 6 de 51
Base UI drives visibility animation through paired data-* attributes ([data-starting-style]/[data-ending-style] for transitions, [data-open]/[data-closed] for keyframe animations) and detects completion via element.getAnimations() — CSS transitions are preferred over CSS animations because they can be smoothly cancelled mid-flight; JS libraries like Motion need explicit keepMounted/render wiring to control mount/unmount timing.
[data-starting-style] = initial style to transition from, [data-ending-style] = final style to transition to. Preferred over animations because closing a popup mid-open smoothly reverses instead of jumping.[data-open] = keyframe style while visible, [data-closed] = keyframe style just before hiding.element.getAnimations() to know when to actually unmount a closing component. Motion's opacity animations register in getAnimations() automatically; if your exit animation doesn't touch opacity (e.g. a translating drawer), animate opacity to a near-1 value like 0.9999 anyway so Base UI can still detect it.open controlled, wrap in <AnimatePresence>, add keepMounted to <Portal>, and pass render={<motion.div initial={...} animate={...} exit={...} />} to Popup.keepMounted set): no <AnimatePresence> — instead animate off state.open inside render={(props, state) => <motion.div animate={{opacity: state.open ? 1 : 0}} />}.mounted flag on first Positioner ref callback, switch which Motion variant (AnimatePresence-exit vs. state.open-driven) is used based on that flag.actionsRef to Root; call actionsRef.current.unmount() from an animation's onAnimationComplete callback for full manual control over unmount timing./* CSS transition */
.Popup {
transition: transform 150ms, opacity 150ms;
&[data-starting-style], &[data-ending-style] { opacity: 0; transform: scale(0.9); }
}
/* CSS keyframe animation */
.Popup[data-open] { animation: scaleIn 250ms ease-out; }
.Popup[data-closed] { animation: scaleOut 250ms ease-in; }
/* Motion — unmounted-by-default popup (Popover/Dialog/Tooltip/Menu) */
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger>Trigger</Popover.Trigger>
<AnimatePresence>
{open && (
<Popover.Portal keepMounted>
<Popover.Positioner>
<Popover.Popup render={<motion.div initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} />}>
Popup
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
)}
</AnimatePresence>
</Popover.Root>
/* Motion — always-kept-mounted popup: animate off state.open, no AnimatePresence */
<Popover.Portal keepMounted>
<Popover.Positioner>
<Popover.Popup render={(props, state) => (
<motion.div {...props} initial={false} animate={{ opacity: state.open ? 1 : 0, scale: state.open ? 1 : 0.8 }} />
)}>
Popup
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
keepMounted + AnimatePresence combination vs. the state.open-driven render function are mutually exclusive patterns — mixing them produces double-animation or premature-unmount bugs.[data-starting-style]/[data-ending-style] — only reach for CSS keyframes or a JS library when you need effects transitions can't express.opacity (even a near-invisible 0.9999 nudge) on components whose real animation doesn't touch it — otherwise Base UI never detects completion and the element lingers unmounted-late or is removed too early.data-* attribute convention shared with the general styling system.render/actionsRef/keepMounted prop contracts.