Skip to main content

Advanced: Coordinating Multiple Layout Animations

Coordinating multiple layout animations across dozens of elements requires careful orchestration. Without a system, animations become chaotic—elements move at different speeds, some complete early, and the overall effect looks disjointed. Framer Motion's variant system provides a declarative way to coordinate motion across nested components.

This article covers advanced patterns: parent-child variant chains, conditional staggering, timing orchestration, and layered animations. These techniques enable professional-grade UI motion in large, complex applications.

Variant System for Orchestration

Variants are named animation states that propagate from parent to child components. When a parent animates to a new variant, all children with matching variants animate together, controlled by parent-level timing rules like staggerChildren.

Here is a dashboard that animates 12 cards entering with staggered motion:

import { motion } from 'framer-motion';

const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
};

const cardVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: 'easeOut' },
},
};

function DashboardCard({ title }) {
return (
<motion.div
variants={cardVariants}
style={{
padding: '20px',
backgroundColor: '#f0f0f0',
borderRadius: '8px',
minHeight: '150px',
}}
>
<h3>{title}</h3>
<p>Card content goes here.</p>
</motion.div>
);
}

export default function Dashboard() {
const cards = [
'Revenue',
'Users',
'Retention',
'Churn',
'ARPU',
'LTV',
'CAC',
'Sessions',
'Bounce Rate',
'Time on Site',
'Conversion',
'AOV',
];

return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '20px',
padding: '20px',
}}
>
{cards.map((card) => (
<DashboardCard key={card} title={card} />
))}
</motion.div>
);
}

The container animates to visible, which triggers all children to animate with a 0.1 s stagger between each. The first child animates after a 0.2 s delay. This creates a cascading entrance effect without explicit per-child timing code.

Hierarchical Staggering

For nested hierarchies (sections with cards, cards with items), use staggering at each level:

const sectionVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
},
},
};

const cardVariants = {
hidden: { opacity: 0, x: -20 },
visible: {
opacity: 1,
x: 0,
transition: {
staggerChildren: 0.05,
},
},
};

const itemVariants = {
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0 },
};

function Section({ title, cards }) {
return (
<motion.div
variants={sectionVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
style={{ marginBottom: '30px' }}
>
<h2>{title}</h2>

<motion.div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '15px',
}}
>
{cards.map((card) => (
<motion.div
key={card.id}
variants={cardVariants}
style={{
padding: '15px',
backgroundColor: '#f5f5f5',
borderRadius: '6px',
}}
>
<h4>{card.title}</h4>
{card.items.map((item, i) => (
<motion.div key={i} variants={itemVariants}>
{item}
</motion.div>
))}
</motion.div>
))}
</motion.div>
</motion.div>
);
}

Sections stagger at 0.15 s intervals. Within each section, cards stagger at 0.05 s. This creates a waterfall entrance: sections appear, then cards within each section cascade.

Conditional Timing

Vary timing based on user interactions or application state:

const listVariants = {
hidden: { opacity: 0 },
visible: (isExpanded) => ({
opacity: 1,
transition: {
staggerChildren: isExpanded ? 0.05 : 0.15,
staggerDirection: isExpanded ? 1 : -1,
},
}),
};

export function CollapsibleList({ isExpanded, items }) {
return (
<motion.ul
custom={isExpanded}
variants={listVariants}
animate={isExpanded ? 'visible' : 'hidden'}
style={{ listStyle: 'none' }}
>
{items.map((item) => (
<motion.li
key={item.id}
variants={{
hidden: { opacity: 0, height: 0 },
visible: { opacity: 1, height: 'auto' },
}}
>
{item.name}
</motion.li>
))}
</motion.ul>
);
}

The custom prop passes isExpanded to variants. When expanded, items stagger at 0.05 s (fast cascade). When collapsed, they stagger in reverse at 0.15 s (slow exit). The staggerDirection: -1 reverses the order on exit.

Orchestrating Fade, Scale, and Rotate

Complex motions combine multiple property animations. Use variants to keep timing consistent:

const modalVariants = {
hidden: { opacity: 0, scale: 0.9, rotate: -5 },
visible: {
opacity: 1,
scale: 1,
rotate: 0,
transition: {
opacity: { duration: 0.2 },
scale: { duration: 0.3, delay: 0.1 },
rotate: { duration: 0.3, delay: 0.1 },
},
},
exit: {
opacity: 0,
scale: 0.9,
rotate: 5,
transition: {
opacity: { duration: 0.2 },
scale: { duration: 0.3 },
rotate: { duration: 0.3 },
},
},
};

function Modal() {
return (
<motion.div
variants={modalVariants}
initial="hidden"
animate="visible"
exit="exit"
>
Modal content
</motion.div>
);
}

The modal fades in immediately, then scales and rotates with a 0.1 s delay. On exit, all properties animate simultaneously at the same speed.

Drag-Driven Coordination

When elements respond to drag input (scroll, swipe), coordinate all child animations to a single parent drag gesture:

import { motion, useMotionValue, useTransform } from 'framer-motion';

export function CarouselWithDrag({ items }) {
const x = useMotionValue(0);
const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0]);
const scale = useTransform(x, [-200, 0, 200], [0.8, 1, 0.8]);

return (
<motion.div
drag="x"
dragElastic={0.2}
onDrag={(e, info) => {
// Manually update if needed
}}
style={{ x }}
>
{items.map((item, i) => (
<motion.div
key={i}
style={{
opacity: i === 0 ? opacity : 1,
scale: i === 0 ? scale : 1,
}}
>
{item.name}
</motion.div>
))}
</motion.div>
);
}

The parent's x motion value drives child opacity and scale. As the user drags, children respond in real-time without explicit per-child handlers.

Gesture-Triggered Orchestration

Combine hover, tap, and focus states to orchestrate complex interactions:

const containerVariants = {
rest: {
transition: {
staggerChildren: 0.07,
staggerDirection: -1,
},
},
hover: {
transition: {
staggerChildren: 0.07,
delayChildren: 0.2,
},
},
};

const itemVariants = {
rest: { opacity: 0.6, scale: 0.95 },
hover: { opacity: 1, scale: 1 },
};

export function CardGrid({ cards }) {
return (
<motion.div
variants={containerVariants}
initial="rest"
whileHover="hover"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '15px',
}}
>
{cards.map((card) => (
<motion.div
key={card.id}
variants={itemVariants}
whileHover={{ scale: 1.05 }}
>
{card.title}
</motion.div>
))}
</motion.div>
);
}

Hovering the grid animates all cards to full opacity and scale with a stagger. Individual card hover adds a slight extra scale for layered feedback.

Performance: Reducing Animation Overhead

Large coordinated animations can overwhelm the browser. Optimize:

  1. Limit active animations: Animate at most 20–30 elements simultaneously. For larger lists, use virtualization.
  2. Use GPU-safe properties: Animate only transform and opacity. Avoid width, height, top, left.
  3. Increase duration slightly: 400–500 ms animations spread measurement cost over more frames (16 ms × 30 frames = 480 ms).
  4. Lazy load content: Fetch detail content after the entrance animation completes.
const containerVariants = {
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
duration: 0.4, // Longer duration = more forgiving on CPU
},
},
};

Key Takeaways

  • Variants propagate from parent to child, enabling coordinated multi-element animation.
  • staggerChildren sequences child animations; delayChildren adds initial delay.
  • Hierarchical staggering (section → card → item) creates layered entrance effects.
  • Conditional timing (based on custom prop) adapts animation to application state.
  • Limit to 20–30 simultaneous animations; use virtualization for large lists.

Frequently Asked Questions

Can I animate elements in reverse order on exit?

Yes. Use staggerDirection: -1 in the exit variant to reverse stagger order. Combined with transition.staggerChildren, this creates polished cascade-out effects.

How do I synchronize animations across sibling containers?

Use a shared parent variant and pass timing via the parent. All descendants inherit the parent's staggerChildren rules automatically.

Can I pause and resume coordinated animations?

Framer Motion doesn't expose pause/resume. Workaround: toggle animate prop to switch variants and resume animations.

What is the maximum number of elements I can stagger smoothly?

On modern devices (4-core CPU), 30–50 elements stagger smoothly at 60 FPS. Test on real low-end devices (2-core, 4GB RAM). Use virtualization for 100+.

Can I nest Reorder.Group inside coordinated animations?

Yes. Each component layer animates independently. Reorder handles its own layout; parent variants handle the container. No conflict if you use distinct layoutId and variant values.

Further Reading