Building Performant Transitions with WAAPI
Transitions are animations triggered by user interactions or state changes. They differ from decorative animations because they serve a functional purpose: showing the user what changed and maintaining spatial continuity as layout shifts. This article teaches you how to use the Web Animations API to choreograph complex transitions in React—page changes, modal opens, gesture responses—while keeping every frame compositor-friendly and responsive.
The Transition Pattern in React
A typical transition has three phases: setup, animate, and cleanup. Here is the pattern:
import { useEffect, useRef, useState } from 'react';
export default function TransitionExample() {
const containerRef = useRef(null);
const [isOpen, setIsOpen] = useState(false);
const handleOpen = async () => {
// Phase 1: Setup (set initial state without animation)
setIsOpen(true);
// Phase 2: Animate (trigger WAAPI animation)
const animation = containerRef.current.animate(
[
{ opacity: 0, transform: 'scale(0.9)' },
{ opacity: 1, transform: 'scale(1)' },
],
{
duration: 300,
easing: 'ease-out',
fill: 'forwards',
}
);
// Phase 3: Cleanup (nothing needed—animation is done)
await animation.finished;
};
const handleClose = async () => {
// Reverse the animation, then update state
const animation = containerRef.current.animate(
[
{ opacity: 1, transform: 'scale(1)' },
{ opacity: 0, transform: 'scale(0.9)' },
],
{
duration: 300,
easing: 'ease-out',
fill: 'forwards',
}
);
await animation.finished;
setIsOpen(false);
};
return (
<div>
<button onClick={isOpen ? handleClose : handleOpen}>
{isOpen ? 'Close' : 'Open'}
</button>
{isOpen && (
<div
ref={containerRef}
style={{
padding: '20px',
backgroundColor: '#4ecdc4',
borderRadius: '8px',
}}
>
Modal Content
</div>
)}
</div>
);
}
The key insight: animate before or after state changes, depending on whether you want the element to fade in (after it mounts) or fade out (before it unmounts).
Coordinated Multi-Element Transitions
Many real transitions involve multiple elements animating in sequence or parallel. Use async/await and Promise.all() to choreograph them:
import { useEffect, useRef, useState } from 'react';
export default function MultiElementTransition() {
const overlayRef = useRef(null);
const contentRef = useRef(null);
const [isOpen, setIsOpen] = useState(false);
const handleOpen = async () => {
setIsOpen(true);
// Animate overlay and content in parallel
await Promise.all([
overlayRef.current.animate(
[{ opacity: 0 }, { opacity: 0.5 }],
{ duration: 300, fill: 'forwards' }
).finished,
contentRef.current.animate(
[
{ opacity: 0, transform: 'translateY(30px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{ duration: 300, easing: 'ease-out', fill: 'forwards' }
).finished,
]);
};
const handleClose = async () => {
// Animate in reverse
await Promise.all([
overlayRef.current.animate(
[{ opacity: 0.5 }, { opacity: 0 }],
{ duration: 300, fill: 'forwards' }
).finished,
contentRef.current.animate(
[
{ opacity: 1, transform: 'translateY(0)' },
{ opacity: 0, transform: 'translateY(30px)' },
],
{ duration: 300, easing: 'ease-out', fill: 'forwards' }
).finished,
]);
setIsOpen(false);
};
return (
<div>
<button onClick={isOpen ? handleClose : handleOpen}>
{isOpen ? 'Close Modal' : 'Open Modal'}
</button>
{isOpen && (
<>
<div
ref={overlayRef}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
opacity: 0,
}}
onClick={handleClose}
/>
<div
ref={contentRef}
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: 'white',
padding: '30px',
borderRadius: '8px',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
minWidth: '300px',
opacity: 0,
}}
>
<h2>Modal Title</h2>
<p>Modal content goes here.</p>
<button onClick={handleClose}>Close</button>
</div>
</>
)}
</div>
);
}
This example animates the overlay and modal content in parallel, creating a cohesive open/close experience.
Staggered Animations
For lists or grids, stagger animations so each element animates with a slight delay:
import { useEffect, useRef, useState } from 'react';
export default function StaggeredListAnimation() {
const itemsRef = useRef([]);
const [items, setItems] = useState([]);
useEffect(() => {
// Simulate loading items
const loadItems = async () => {
const newItems = Array.from({ length: 5 }, (_, i) => i);
setItems(newItems);
// Animate each item in sequence
for (let i = 0; i < newItems.length; i++) {
if (itemsRef.current[i]) {
itemsRef.current[i].animate(
[
{ opacity: 0, transform: 'translateY(10px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{
duration: 300,
delay: i * 100, // Stagger by 100ms
easing: 'ease-out',
fill: 'forwards',
}
);
}
}
};
loadItems();
}, []);
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.map((item, i) => (
<li
key={item}
ref={(el) => {
itemsRef.current[i] = el;
}}
style={{
padding: '10px',
marginBottom: '10px',
backgroundColor: '#4ecdc4',
opacity: 0,
}}
>
Item {item + 1}
</li>
))}
</ul>
);
}
Each item fades in and slides up with a 100ms stagger, creating a cascading effect.
Gesture-Driven Transitions
Respond to user input (scroll, mouse, touch) with animations:
import { useEffect, useRef, useState } from 'react';
export default function GestureTransition() {
const cardRef = useRef(null);
const [translateY, setTranslateY] = useState(0);
useEffect(() => {
const handleScroll = () => {
const scrollTop = window.scrollY;
setTranslateY(Math.min(scrollTop * 0.5, 200)); // Parallax effect
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return (
<div
ref={cardRef}
style={{
transform: `translateY(${translateY}px)`,
width: '200px',
height: '200px',
backgroundColor: '#4ecdc4',
position: 'sticky',
top: '100px',
}}
>
Parallax Card
</div>
);
}
As the user scrolls, the card moves slower than the page, creating a parallax effect.
Avoiding Transition Jank
Common mistakes that cause janky transitions:
Mistake 1: Animating Layout Properties
// Bad: animating `left` triggers layout recalculation
const animation = element.animate(
[{ left: '0px' }, { left: '100px' }],
{ duration: 300 }
);
// Good: use transform instead
const animation = element.animate(
[{ transform: 'translateX(0px)' }, { transform: 'translateX(100px)' }],
{ duration: 300 }
);
Mistake 2: Heavy State Updates During Animation
// Bad: state update causes React render during animation
const handleClick = async () => {
const animation = elementRef.current.animate(...);
setIsOpen(true); // Causes React render, may interrupt animation
await animation.finished;
};
// Good: animate first, then update state
const handleClick = async () => {
const animation = elementRef.current.animate(...);
await animation.finished;
setIsOpen(true); // State update after animation completes
};
Mistake 3: Animating Too Many Elements
// Bad: animating 1000 elements causes jank
items.forEach((item, i) => {
item.animate([...], { duration: 300 });
});
// Good: animate only visible items or group animations
const visibleItems = items.slice(0, 10);
visibleItems.forEach((item, i) => {
item.animate([...], { duration: 300, delay: i * 50 });
});
Transition Timing Guidelines
| Type | Duration | Easing | Use Case |
|---|---|---|---|
| Subtle (color, opacity) | 150–250 ms | ease-out | Hover states, toggles |
| Standard (position, scale) | 250–400 ms | ease-out-cubic | Modal opens, page transitions |
| Complex (multi-element) | 400–600 ms | ease-in-out | Choreographed sequences |
| Emphasis (bounce, elastic) | 500–800 ms | ease-out-bounce | Attention-grabbing animations |
Use easing curves that start fast and slow down (ease-out) for transitions that close or exit; use ease-in-out for sequences. Avoid linear easing for user-triggered animations—it feels robotic.
Key Takeaways
- Choreograph transitions with
async/awaitandPromise.all()to coordinate multiple elements. - Always animate compositor-friendly properties (
transform,opacity) to avoid jank. - Stagger animations on lists to create visual continuity and guide the user's attention.
- Respond to gestures (scroll, mouse, touch) with smooth animations that feel responsive.
- Avoid heavy state updates or complex React renders during animations; defer them until after the animation completes.
Frequently Asked Questions
How do I animate a page transition when routing changes?
Use a router library that exposes lifecycle hooks (Next.js with useTransitionRouter, React Router with useNavigate). Animate out the old page, wait for the animation to finish, then trigger the route change:
const navigate = useNavigate();
const handleNavigation = async () => {
await elementRef.current.animate([...], { duration: 300 }).finished;
navigate('/next-page');
};
Can I use CSS transitions instead of WAAPI for simple transitions?
Yes, CSS transitions work fine for simple state changes. However, CSS transitions cannot be precisely controlled (paused, seeked, reversed) from JavaScript. Use CSS transitions for fire-and-forget animations and WAAPI when you need choreography.
How do I handle interruptions (user clicks while animation is running)?
Cancel the current animation and start a new one:
const handleClick = async () => {
if (currentAnimationRef.current) {
currentAnimationRef.current.cancel();
}
currentAnimationRef.current = element.animate([...], { duration: 300 });
await currentAnimationRef.current.finished;
};
Is there a performance difference between staggering with delay vs looping with setTimeout?
Staggering with the delay option is simpler and less error-prone. Using setTimeout to stagger requires manual loop management and is more likely to cause bugs. Prefer delay for simple staggering.
How do I animate layout changes (when an element's size changes)?
Use the Web Animations API with getAnimations() to detect layout changes, then animate the size difference. This is advanced; for most use cases, triggering an animation on a container that remains fixed-size is simpler.