5.4 KiB
Animation Patterns
Animate component state changes with CSS or JavaScript libraries.
Reference: Base UI Animation Handbook
CSS Transitions (Preferred)
Use data-starting-style / data-ending-style for smooth transitions:
.popup {
transform-origin: var(--transform-origin);
transition:
transform 150ms,
opacity 150ms;
}
.popup[data-starting-style],
.popup[data-ending-style] {
opacity: 0;
transform: scale(0.9);
}
Why transitions over animations: Transitions can be cancelled midway. If user closes a popup before it finishes opening, it smoothly animates to closed without abrupt changes.
CSS Animations
Use data-open / data-closed for keyframe animations:
.popup[data-open] {
animation: scaleIn 200ms ease-out;
}
.popup[data-closed] {
animation: scaleOut 200ms ease-in;
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes scaleOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.9);
}
}
JavaScript Animation Libraries
Unmounted Components (Dialog, Popover, Menu)
Components unmounted from DOM when closed need special handling for exit animations.
Pattern: Controlled open + keepMounted on Portal + AnimatePresence
function AnimatedPopover() {
const [open, setOpen] = useState(false);
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger>Open</Popover.Trigger>
<AnimatePresence>
{open && (
<Popover.Portal keepMounted>
<Popover.Positioner>
<Popover.Popup
render={
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
/>
}
>
Content
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
)}
</AnimatePresence>
</Popover.Root>
);
}
Kept Mounted Components
Components with keepMounted stay in DOM when closed — use state-based animation:
<Popover.Popup
render={(props, state) => (
<motion.div
{...props}
initial={false}
animate={{
opacity: state.open ? 1 : 0,
scale: state.open ? 1 : 0.9,
}}
/>
)}
>
Content
</Popover.Popup>
Manual Unmount Control
Use actionsRef for full lifecycle control:
function ManualUnmount() {
const [open, setOpen] = useState(false);
const actionsRef = useRef(null);
return (
<Popover.Root open={open} onOpenChange={setOpen} actionsRef={actionsRef}>
<Popover.Trigger>Open</Popover.Trigger>
<AnimatePresence>
{open && (
<Popover.Portal keepMounted>
<Popover.Popup
render={
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
onAnimationComplete={() => {
if (!open) actionsRef.current.unmount();
}}
/>
}
/>
</Popover.Portal>
)}
</AnimatePresence>
</Popover.Root>
);
}
Animation Detection
Base UI uses element.getAnimations() to detect when animations finish before unmounting.
Important: For animations without opacity (e.g., translating drawer), include opacity: 0.9999 so detection works:
<motion.div
animate={{
x: state.open ? 0 : -300,
opacity: state.open ? 1 : 0.9999, // Enables detection
}}
/>
Height Animation (Accordion)
Animating height: auto requires measurement.
Steps:
- On open: measure
scrollHeight, animate 0 → measured - After animation: set to
auto(allows content resize) - On close: set explicit height, animate to 0
.accordion-content {
overflow: hidden;
transition: height 200ms ease-out;
}
.accordion-content[data-open] {
animation: slideDown 200ms ease-out;
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--accordion-content-height);
}
}
Double-rAF trick: When closing, set explicit height before animating to 0. Browser needs a frame to register height before transitioning.
Reference: Radix Collapsible
Reduced Motion
It's not always required to completely nuke transitions/animations. Sometimes we can instead reduce their duration and delay. People enabling reduced motion often still benefit from some transition - it helps orientation and avoids jarring state changes.
--transition-duration: 200ms;
--animation-duration: 200ms;
@media (prefers-reduced-motion: reduce) {
.popup {
--transition-duration: 50ms;
--animation-duration: 50ms;
}
}
Remove non-essential motion - e.g. parallax, large transforms, bouncing, zooming, auto-playing animations.
.element {
animation: bounce 1s infinite;
}
@media (prefers-reduced-motion: reduce) {
.element {
animation: none;
}
}
See Also
- Styling — data attributes for state
- Collection — exit animations in lists