Skip to main content

Coordinating Multiple Animations in React

Most real animations involve multiple elements moving in concert. A modal opens with the overlay fading in while the content scales and slides. A carousel advances with items sliding out and new items sliding in. Coordinating these animations without race conditions, managing timing, and handling cancellations is complex. This article teaches you how to choreograph multi-element animations in React using async patterns, animation groups, and lifecycle management.

The Coordination Challenge

When you animate multiple elements independently, you risk:

  1. Timing mismatches: Elements animate at different speeds or start/stop times.
  2. Race conditions: A user clicks while an animation is in progress, causing conflicting animations.
  3. Memory leaks: Animations reference detached DOM elements.
  4. State inconsistency: React state and animation state diverge.

Sequential Animation Pattern

Run animations one after the other using async/await:

import { useRef } from 'react';

export default function SequentialAnimations() {
const item1Ref = useRef(null);
const item2Ref = useRef(null);
const item3Ref = useRef(null);

const handleAnimateSequence = async () => {
try {
// Animate item 1
await item1Ref.current.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 500, fill: 'forwards' }
).finished;

// After item 1 finishes, animate item 2
await item2Ref.current.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 500, fill: 'forwards' }
).finished;

// After item 2 finishes, animate item 3
await item3Ref.current.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 500, fill: 'forwards' }
).finished;

console.log('All animations complete');
} catch (e) {
// Animation was cancelled
console.log('Animation cancelled:', e);
}
};

return (
<div>
<button onClick={handleAnimateSequence}>Start</button>
<div ref={item1Ref} style={{ opacity: 0 }}>Item 1</div>
<div ref={item2Ref} style={{ opacity: 0 }}>Item 2</div>
<div ref={item3Ref} style={{ opacity: 0 }}>Item 3</div>
</div>
);
}

Sequential animations create a cascading effect: item 1 fades in, then item 2, then item 3.

Parallel Animation Pattern

Run animations simultaneously using Promise.all():

import { useRef } from 'react';

export default function ParallelAnimations() {
const overlayRef = useRef(null);
const contentRef = useRef(null);
const buttonRef = useRef(null);

const handleAnimateParallel = async () => {
try {
await Promise.all([
overlayRef.current.animate(
[{ opacity: 0 }, { opacity: 0.5 }],
{ duration: 300, fill: 'forwards' }
).finished,
contentRef.current.animate(
[{ transform: 'scale(0.8)', opacity: 0 }, { transform: 'scale(1)', opacity: 1 }],
{ duration: 300, easing: 'ease-out', fill: 'forwards' }
).finished,
buttonRef.current.animate(
[{ transform: 'translateY(20px)' }, { transform: 'translateY(0)' }],
{ duration: 300, easing: 'ease-out', fill: 'forwards' }
).finished,
]);

console.log('All animations complete');
} catch (e) {
console.log('Animation cancelled:', e);
}
};

return (
<div>
<button onClick={handleAnimateParallel}>Open Modal</button>
<div ref={overlayRef} style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0)' }} />
<div ref={contentRef} style={{ padding: '20px', backgroundColor: 'white' }}>
Modal content
<button ref={buttonRef}>Close</button>
</div>
</div>
);
}

Parallel animations make interactions feel responsive because all elements move at once.

Staggered Animation Pattern

Animate multiple items with a time offset between each:

import { useRef, useState } from 'react';

export default function StaggeredAnimations() {
const itemRefs = useRef([]);
const [items, setItems] = useState([]);

const handleLoad = async () => {
const newItems = Array.from({ length: 5 }, (_, i) => i);
setItems(newItems);

// Animate each item with a 100ms stagger
const promises = newItems.map((_, index) => {
return new Promise((resolve) => {
setTimeout(async () => {
if (itemRefs.current[index]) {
await itemRefs.current[index].animate(
[
{ opacity: 0, transform: 'translateY(10px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{ duration: 300, easing: 'ease-out', fill: 'forwards' }
).finished;
}
resolve();
}, index * 100); // Stagger each animation by 100ms
});
});

await Promise.all(promises);
console.log('All items loaded');
};

return (
<div>
<button onClick={handleLoad}>Load Items</button>
<ul>
{items.map((item, i) => (
<li
key={item}
ref={(el) => {
itemRefs.current[i] = el;
}}
style={{ opacity: 0, padding: '10px', backgroundColor: '#4ecdc4' }}
>
Item {item + 1}
</li>
))}
</ul>
</div>
);
}

Staggered animations guide the user's eye and make list loads feel organic.

Handling Interruptions (Race Conditions)

When a user interacts while an animation is in progress, cancel the old animation and start a new one:

import { useRef, useState } from 'react';

export default function InterruptibleAnimation() {
const boxRef = useRef(null);
const animationRef = useRef(null);
const [position, setPosition] = useState(0);

const animateTo = async (target) => {
// Cancel any running animation
if (animationRef.current) {
animationRef.current.cancel();
}

try {
animationRef.current = boxRef.current.animate(
[
{ transform: `translateX(${position}px)` },
{ transform: `translateX(${target}px)` },
],
{
duration: 500,
easing: 'ease-out',
fill: 'forwards',
}
);

await animationRef.current.finished;
setPosition(target);
} catch (e) {
// Animation was cancelled; ignore
}
};

return (
<div>
<button onClick={() => animateTo(100)}>Position 100px</button>
<button onClick={() => animateTo(200)}>Position 200px</button>
<button onClick={() => animateTo(0)}>Reset</button>
<div
ref={boxRef}
style={{
width: '50px',
height: '50px',
backgroundColor: '#4ecdc4',
transform: `translateX(${position}px)`,
}}
/>
</div>
);
}

Cancelling the old animation before starting a new one prevents conflicting transforms.

Animation Manager Pattern

For complex scenarios with many animations, create a reusable animation manager:

// animationManager.js
class AnimationManager {
constructor() {
this.animations = new Map(); // key → animation
this.cancelledIds = new Set();
}

cancel(id) {
if (this.animations.has(id)) {
this.animations.get(id).cancel();
this.animations.delete(id);
this.cancelledIds.add(id);
}
}

cancelAll() {
this.animations.forEach((anim) => anim.cancel());
this.animations.clear();
}

async run(id, element, keyframes, options) {
// Cancel any existing animation with this ID
this.cancel(id);

// Create and store the animation
const animation = element.animate(keyframes, options);
this.animations.set(id, animation);

try {
await animation.finished;
return { success: true };
} catch (e) {
if (this.cancelledIds.has(id)) {
// Animation was explicitly cancelled
this.cancelledIds.delete(id);
return { success: false, reason: 'cancelled' };
}
throw e;
} finally {
this.animations.delete(id);
}
}

async runSequence(sequence) {
for (const step of sequence) {
const { id, element, keyframes, options } = step;
const result = await this.run(id, element, keyframes, options);
if (!result.success) break; // Stop if animation was cancelled
}
}

async runParallel(animations) {
const promises = animations.map(({ id, element, keyframes, options }) =>
this.run(id, element, keyframes, options)
);
return Promise.all(promises);
}
}

export default new AnimationManager();

Use the manager in a component:

import { useRef } from 'react';
import animationManager from './animationManager';

export default function ManagedAnimations() {
const item1Ref = useRef(null);
const item2Ref = useRef(null);

const handleStartSequence = () => {
animationManager.runSequence([
{
id: 'item1-fade',
element: item1Ref.current,
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 500 },
},
{
id: 'item2-fade',
element: item2Ref.current,
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 500 },
},
]);
};

const handleCancel = () => {
animationManager.cancelAll();
};

return (
<div>
<button onClick={handleStartSequence}>Start</button>
<button onClick={handleCancel}>Cancel</button>
<div ref={item1Ref} style={{ opacity: 0 }}>Item 1</div>
<div ref={item2Ref} style={{ opacity: 0 }}>Item 2</div>
</div>
);
}

An animation manager centralizes lifecycle management and prevents bugs from forgotten cleanup.

Synchronizing with State

Avoid updating React state during animations. Instead, wait for animations to finish, then update state:

import { useRef, useState } from 'react';

export default function StateSynchronizedAnimation() {
const contentRef = useRef(null);
const [isOpen, setIsOpen] = useState(false);

const handleToggle = async () => {
if (isOpen) {
// Animate out, then close
await contentRef.current.animate(
[{ opacity: 1 }, { opacity: 0 }],
{ duration: 300, fill: 'forwards' }
).finished;
setIsOpen(false);
} else {
// Open, then animate in
setIsOpen(true);
await contentRef.current.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 300, fill: 'forwards' }
).finished;
}
};

return (
<div>
<button onClick={handleToggle}>{isOpen ? 'Close' : 'Open'}</button>
{isOpen && (
<div ref={contentRef} style={{ opacity: 0 }}>
Content
</div>
)}
</div>
);
}

This ensures that React state changes align with animation completion, preventing visual inconsistency.

Key Takeaways

  • Use async/await and Promise.all() to orchestrate sequential and parallel animations.
  • Cancel running animations before starting new ones to prevent race conditions and conflicting state.
  • Stagger animations to guide attention and create organic, flowing interactions.
  • Use an animation manager for complex scenarios with many simultaneous animations.
  • Defer React state updates until after animations complete to maintain consistency.

Frequently Asked Questions

What happens if I cancel an animation while it is in progress?

The animation stops immediately, and the element retains whatever styles it had at the moment of cancellation. If you want to jump to the final state, set the styles explicitly after cancelling:

animationRef.current.cancel();
element.style.transform = 'translateX(100px)'; // Final state

Can I pause an animation without cancelling it?

Yes, use animation.pause() instead of cancel():

animationRef.current.pause();
// Later, resume:
animationRef.current.play();

How do I handle cleanup when a component unmounts while animating?

Cancel all animations in the cleanup function:

useEffect(() => {
return () => {
if (animationRef.current) {
animationRef.current.cancel();
}
};
}, []);

Without this, animations may reference detached DOM and cause errors.

Can I coordinate animations across different components?

Yes, use a shared state management solution (Context, Redux, Zustand) to coordinate animations across components. Or, use an animation manager (like the one in this article) that lives outside components.

What is the maximum number of simultaneous animations I can run?

There is no hard limit, but practical performance degrades rapidly. On a typical device, you can smoothly animate 20–50 elements simultaneously. Profile with DevTools to measure your specific scenario.

How do I create a "smooth interruption" where the animation reverses smoothly when interrupted?

Calculate the current animation progress, then animate from that point to the new target:

const currentTime = animation.currentTime; // Milliseconds elapsed
const progress = currentTime / animation.effect.getTiming().duration; // 0–1
const reversedKeyframes = interpolateFromProgress(keyframes, progress);
animation.cancel();
newAnimation = element.animate(reversedKeyframes, { ...options, duration: options.duration * (1 - progress) });

This is advanced; for most cases, a simple cancel-and-restart is sufficient.

Further Reading