Capítulo 6 de 51

Chapter 6: Animation

Core Idea

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.

Key Concepts

  • CSS transitions (preferred): [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.
  • CSS animations: [data-open] = keyframe style while visible, [data-closed] = keyframe style just before hiding.
  • Completion detection: Base UI calls 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.
  • Three Motion integration patterns, chosen by the component's default mount behavior:
    1. Unmounted-by-default popups (Popover, Dialog, Tooltip, Menu): make open controlled, wrap in <AnimatePresence>, add keepMounted to <Portal>, and pass render={<motion.div initial={...} animate={...} exit={...} />} to Popup.
    2. Always-kept-mounted popups (keepMounted set): no <AnimatePresence> — instead animate off state.open inside render={(props, state) => <motion.div animate={{opacity: state.open ? 1 : 0}} />}.
    3. Select (unmounted until first interaction, then stays mounted): hybrid — track a local mounted flag on first Positioner ref callback, switch which Motion variant (AnimatePresence-exit vs. state.open-driven) is used based on that flag.
  • Manual unmount control: pass an actionsRef to Root; call actionsRef.current.unmount() from an animation's onAnimationComplete callback for full manual control over unmount timing.

Code Examples

/* 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>
  • What it demonstrates: the keepMounted + AnimatePresence combination vs. the state.open-driven render function are mutually exclusive patterns — mixing them produces double-animation or premature-unmount bugs.

Key Takeaways

  1. Default to CSS transitions with [data-starting-style]/[data-ending-style] — only reach for CSS keyframes or a JS library when you need effects transitions can't express.
  2. For Motion, always animate 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.
  3. Select needs the hybrid pattern (track first-mount) because it starts unmounted but never fully unmounts again after first open — using either pure pattern alone breaks its second-open animation.

Connects To

  • ch005 (Styling): data-* attribute convention shared with the general styling system.
  • ch033 (Popover), ch021 (Dialog), ch027 (Menu), ch038 (Select): components used in every example above; check their own chapters for the full render/actionsRef/keepMounted prop contracts.