Radix Primitives are built with animation in mind. Components expose data attributes that make it easy to target different states, and the Presence component handles exit animations.
Data State Attributes
Most interactive Radix components provide data-state attributes that reflect their current state. You can use these to apply animations:
import * as Dialog from '@radix-ui/react-dialog';
import './styles.css';
function MyDialog() {
return (
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<Dialog.Title>Animated Dialog</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
CSS Transitions
Use CSS transitions with data attributes for simple animations:
.dialog-overlay {
background-color: rgba(0, 0, 0, 0.5);
position: fixed;
inset: 0;
transition: opacity 150ms ease-in-out;
}
.dialog-overlay[data-state='open'] {
opacity: 1;
}
.dialog-overlay[data-state='closed'] {
opacity: 0;
}
.dialog-content {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 8px;
padding: 24px;
transition: all 200ms ease-in-out;
}
.dialog-content[data-state='open'] {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.dialog-content[data-state='closed'] {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
Components expose data-state with values like "open", "closed", "checked", "unchecked", etc.
CSS Keyframe Animations
Use CSS keyframes for more complex animations:
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-12px);
}
}
.dropdown-content[data-state='open'] {
animation: slideDown 200ms ease-out;
}
.dropdown-content[data-state='closed'] {
animation: slideUp 150ms ease-in;
}
Example with Accordion
import * as Accordion from '@radix-ui/react-accordion';
import './accordion.css';
function AnimatedAccordion() {
return (
<Accordion.Root type="single" collapsible>
<Accordion.Item value="item-1">
<Accordion.Header>
<Accordion.Trigger className="accordion-trigger">
What is Radix?
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content className="accordion-content">
Radix is an open-source UI component library.
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
);
}
.accordion-content {
overflow: hidden;
}
.accordion-content[data-state='open'] {
animation: slideDown 300ms cubic-bezier(0.87, 0, 0.13, 1);
}
.accordion-content[data-state='closed'] {
animation: slideUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes slideUp {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
Radix provides CSS variables like --radix-accordion-content-height and --radix-collapsible-content-width for animating dynamic dimensions.
Exit Animations
Radix uses the Presence component internally to handle exit animations. The component stays mounted while animating out, allowing CSS animations to complete.
Force Mount for Animation Control
For advanced animation control, use the forceMount prop:
import * as Dialog from '@radix-ui/react-dialog';
function ControlledDialog() {
return (
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal forceMount>
<Dialog.Overlay forceMount />
<Dialog.Content forceMount>
Content
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
When using forceMount, you’re responsible for handling the component’s presence and animations manually.
Animation Libraries
Framer Motion
Use Framer Motion with the asChild prop for advanced animations:
import * as Dialog from '@radix-ui/react-dialog';
import { motion, AnimatePresence } from 'framer-motion';
function MotionDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger>Open</Dialog.Trigger>
<AnimatePresence>
{open && (
<Dialog.Portal forceMount>
<Dialog.Overlay asChild forceMount>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
</Dialog.Overlay>
<Dialog.Content asChild forceMount>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<Dialog.Title>Animated with Framer Motion</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
</motion.div>
</Dialog.Content>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
);
}
Install Framer Motion
npm install framer-motion
Use controlled state
Control the open state to work with AnimatePresence:const [open, setOpen] = useState(false);
Add forceMount
Use forceMount to keep the component mounted during animations:<Dialog.Content asChild forceMount>
Wrap with motion components
Use motion.div with asChild to apply Framer Motion animations:<Dialog.Content asChild forceMount>
<motion.div {...motionProps}>
{/* content */}
</motion.div>
</Dialog.Content>
React Spring
import * as Collapsible from '@radix-ui/react-collapsible';
import { useSpring, animated } from '@react-spring/web';
function SpringCollapsible() {
const [open, setOpen] = useState(false);
const styles = useSpring({
opacity: open ? 1 : 0,
height: open ? 'auto' : 0,
config: { tension: 300, friction: 25 },
});
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Content asChild forceMount>
<animated.div style={styles}>
Content with React Spring animation
</animated.div>
</Collapsible.Content>
</Collapsible.Root>
);
}
Common Animation Patterns
Slide and Fade
@keyframes slideUpAndFade {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
[data-state='open'] {
animation: slideUpAndFade 200ms ease-out;
}
Scale and Fade
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
[data-state='open'] {
animation: scaleIn 150ms ease-out;
}
Rotate Icon
.accordion-trigger[data-state='open'] .icon {
transform: rotate(180deg);
transition: transform 200ms ease-in-out;
}
Performance Tips
- Use
transform and opacity for better performance (GPU-accelerated)
- Avoid animating
width, height, or margin when possible
- Use
will-change sparingly and remove it after animations complete
- Test animations on lower-end devices
/* Good - GPU accelerated */
.dialog-content {
transition: opacity 200ms, transform 200ms;
}
/* Avoid - forces layout recalculation */
.dialog-content {
transition: width 200ms, height 200ms;
}