Smooth animations separate professional React apps from amateur ones. A janky fade-in, stuttering carousel, or laggy modal transition immediately signals poor performance to users. But creating 60fps animations in React requires understanding the browser’s rendering pipeline, choosing the right properties to animate, and avoiding common pitfalls that block the main thread.
The challenge isn’t just making things move—it’s making them move smoothly while maintaining React’s declarative paradigm. Every animation is a series of rapid state changes that can trigger expensive re-renders, layout recalculations, and style updates. Master animation performance, and you’ll create interfaces that feel fluid and responsive even on budget devices.
Understanding the Browser Rendering Pipeline
Before optimizing animations, understand what happens when the browser renders a frame:
// Browser rendering pipeline for each frame (16.67ms for 60fps)
interface RenderingPipeline {
// 1. JavaScript (1-2ms budget)
javascript: {
reactRender: 'Component updates and reconciliation';
eventHandlers: 'User interaction processing';
animationCallbacks: 'requestAnimationFrame callbacks';
};
// 2. Style Calculation (1-2ms budget)
style: {
cssRecalculation: 'Which rules apply to which elements';
inheritance: 'Cascading and computed styles';
mediaQueries: 'Responsive breakpoint calculations';
};
// 3. Layout/Reflow (2-3ms budget)
layout: {
elementPositions: 'Where each element goes';
elementSizes: 'How big each element should be';
textWrapping: 'How text flows in containers';
};
// 4. Paint (2-3ms budget)
paint: {
fillPixels: 'Draw pixels for each element';
textRasterization: 'Convert text to pixels';
imageDecoding: 'Process image data';
};
// 5. Composite (1ms budget)
composite: {
layerCombination: 'Combine painted layers';
transformations: 'Apply 3D transforms';
opacity: 'Alpha blending';
};
}
// Animation performance hierarchy (fastest to slowest)
const animationCosts = {
// ✅ Composite-only changes (GPU accelerated)
fastest: ['transform', 'opacity'],
// ⚠️ Paint-only changes (requires repaint)
moderate: ['color', 'background-color', 'box-shadow', 'border-radius'],
// ❌ Layout changes (full pipeline)
slowest: ['width', 'height', 'left', 'top', 'margin', 'padding', 'border'],
};The golden rule: animate properties that only trigger compositing. Transform and opacity changes happen on the GPU and bypass most of the rendering pipeline.
CSS Transform-Based Animations
Hardware-Accelerated Transitions
// Performant modal animation using transforms
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
function PerformantModal({ isOpen, onClose, children }: ModalProps) {
const [isVisible, setIsVisible] = useState(false);
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
if (isOpen) {
setShouldRender(true);
// Force layout before animating
requestAnimationFrame(() => {
setIsVisible(true);
});
} else {
setIsVisible(false);
// Remove from DOM after animation completes
const timer = setTimeout(() => {
setShouldRender(false);
}, 300); // Match CSS transition duration
return () => clearTimeout(timer);
}
}, [isOpen]);
if (!shouldRender) return null;
return (
<div className={`modal-overlay ${isVisible ? 'modal-overlay--visible' : ''}`} onClick={onClose}>
<div
className={`modal-content ${isVisible ? 'modal-content--visible' : ''}`}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}
// CSS using transform for hardware acceleration
const modalStyles = `
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0);
/* Hardware acceleration - Force GPU layer */
transform: translateZ(0);
transition: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.modal-overlay--visible {
background-color: rgba(0, 0, 0, 0.5);
pointer-events: auto;
}
.modal-content {
position: absolute;
top: 50%;
left: 50%;
max-width: 90vw;
max-height: 90vh;
background: white;
border-radius: 8px;
/* Use transform instead of changing top/left */
transform: translate(-50%, -50%) scale(0.9);
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-content--visible {
transform: translate(-50%, -50%) scale(1);
}
`;List Animation with Transforms
// Performant list animations avoiding layout thrashing
interface AnimatedListItem {
id: string;
content: React.ReactNode;
isNew?: boolean;
isRemoving?: boolean;
}
function AnimatedList({ items }: { items: AnimatedListItem[] }) {
const [displayItems, setDisplayItems] = useState<AnimatedListItem[]>([]);
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
useEffect(() => {
const previousPositions = new Map<string, DOMRect>();
// Store current positions
itemRefs.current.forEach((element, id) => {
if (element) {
previousPositions.set(id, element.getBoundingClientRect());
}
});
// Update items
setDisplayItems(items);
// Apply FLIP animation technique
requestAnimationFrame(() => {
itemRefs.current.forEach((element, id) => {
if (element && previousPositions.has(id)) {
const previousRect = previousPositions.get(id)!;
const currentRect = element.getBoundingClientRect();
const deltaX = previousRect.left - currentRect.left;
const deltaY = previousRect.top - currentRect.top;
if (deltaX !== 0 || deltaY !== 0) {
// Move element to previous position without transition
element.style.transition = 'none';
element.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
// Animate to current position
requestAnimationFrame(() => {
element.style.transition = 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1)';
element.style.transform = 'translate(0, 0)';
});
}
}
});
});
}, [items]);
return (
<div className="animated-list">
{displayItems.map((item) => (
<div
key={item.id}
ref={(el) => {
if (el) {
itemRefs.current.set(item.id, el);
}
}}
className={`list-item ${
item.isNew ? 'list-item--entering' : ''
} ${item.isRemoving ? 'list-item--leaving' : ''}`}
>
{item.content}
</div>
))}
</div>
);
}
// CSS for enter/leave animations
const listAnimationStyles = `
.list-item {
/* Hardware acceleration */
transform: translateZ(0);
}
.list-item--entering {
animation: itemEnter 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.list-item--leaving {
animation: itemLeave 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes itemEnter {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes itemLeave {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
}
`;React Spring for Complex Animations
Physics-Based Animations
// High-performance spring animations with react-spring
import { useSpring, animated, useTransition, config } from '@react-spring/web';
interface SpringCardProps {
isHovered: boolean;
onClick: () => void;
children: React.ReactNode;
}
function SpringCard({ isHovered, onClick, children }: SpringCardProps) {
// Spring animation with optimized config
const springProps = useSpring({
transform: isHovered ? 'translateY(-8px) scale(1.02)' : 'translateY(0px) scale(1)',
boxShadow: isHovered ? '0 20px 40px rgba(0, 0, 0, 0.15)' : '0 2px 10px rgba(0, 0, 0, 0.1)',
// Optimized spring config for performance
config: {
tension: 300, // Stiffness of spring
friction: 30, // Damping
mass: 1, // Mass of object
},
// Optimize by skipping intermediate values
immediate: false,
});
return (
<animated.div style={springProps} onClick={onClick} className="spring-card">
{children}
</animated.div>
);
}
// List transitions with staggered animations
interface ListTransitionProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
getKey: (item: T) => string;
}
function StaggeredListTransition<T>({ items, renderItem, getKey }: ListTransitionProps<T>) {
const transitions = useTransition(items, {
from: {
opacity: 0,
transform: 'translateX(-100px) scale(0.8)',
},
enter: (item, index) => async (next) => {
// Stagger animations by index
await new Promise((resolve) => setTimeout(resolve, index * 50));
await next({
opacity: 1,
transform: 'translateX(0px) scale(1)',
});
},
leave: {
opacity: 0,
transform: 'translateX(100px) scale(0.8)',
},
keys: getKey,
// Performance optimizations
config: config.gentle,
trail: 50, // Delay between items
});
return (
<div className="staggered-list">
{transitions((style, item) => (
<animated.div style={style} className="staggered-item">
{renderItem(item)}
</animated.div>
))}
</div>
);
}
// Gesture-based interactions
import { useDrag } from '@use-gesture/react';
function DraggableCard({ onDismiss }: { onDismiss: () => void }) {
const [{ x, rotate, scale, opacity }, api] = useSpring(() => ({
x: 0,
rotate: 0,
scale: 1,
opacity: 1,
config: config.wobbly,
}));
const bind = useDrag(({ active, movement: [mx], direction: [xDir], velocity: [vx] }) => {
const trigger = vx > 0.2 || (Math.abs(mx) > 100 && !active);
if (trigger) {
// Animate off screen
api.start({
x: (200 + window.innerWidth) * xDir,
rotate: xDir * 10,
scale: 0.8,
opacity: 0,
config: config.default,
onResolve: onDismiss,
});
} else {
// Return to center
api.start({
x: active ? mx : 0,
rotate: active ? (mx / 100) * 5 : 0,
scale: active ? 1.05 : 1,
opacity: 1,
immediate: active,
config: active ? { tension: 800, friction: 50 } : config.wobbly,
});
}
});
return (
<animated.div
{...bind()}
style={{
x,
rotate,
scale,
opacity,
touchAction: 'none', // Prevent scrolling during drag
}}
className="draggable-card"
>
Swipe me!
</animated.div>
);
}Frame Rate Monitoring and Optimization
Performance Monitoring for Animations
// Animation performance monitor
class AnimationPerformanceMonitor {
private frameCount = 0;
private lastTime = performance.now();
private frameRates: number[] = [];
private isMonitoring = false;
private onSlowFrame?: (fps: number) => void;
constructor(
private config: {
targetFPS?: number;
warningThreshold?: number;
sampleSize?: number;
} = {},
) {
this.config.targetFPS = config.targetFPS || 60;
this.config.warningThreshold = config.warningThreshold || 45;
this.config.sampleSize = config.sampleSize || 120; // 2 seconds at 60fps
}
startMonitoring(onSlowFrame?: (fps: number) => void): void {
if (this.isMonitoring) return;
this.isMonitoring = true;
this.onSlowFrame = onSlowFrame;
this.frameCount = 0;
this.lastTime = performance.now();
this.frameRates = [];
this.measureFrame();
}
stopMonitoring(): void {
this.isMonitoring = false;
}
private measureFrame = (): void => {
if (!this.isMonitoring) return;
const currentTime = performance.now();
const frameDuration = currentTime - this.lastTime;
this.lastTime = currentTime;
// Calculate instantaneous FPS
const fps = 1000 / frameDuration;
this.frameRates.push(fps);
// Keep only recent samples
if (this.frameRates.length > this.config.sampleSize!) {
this.frameRates.shift();
}
// Check for slow frames
if (fps < this.config.warningThreshold! && this.onSlowFrame) {
this.onSlowFrame(fps);
}
this.frameCount++;
requestAnimationFrame(this.measureFrame);
};
getAverageFPS(): number {
if (this.frameRates.length === 0) return 0;
return this.frameRates.reduce((sum, fps) => sum + fps, 0) / this.frameRates.length;
}
getPercentile(percentile: number): number {
if (this.frameRates.length === 0) return 0;
const sorted = [...this.frameRates].sort((a, b) => a - b);
const index = Math.floor((percentile / 100) * sorted.length);
return sorted[index];
}
getPerformanceGrade(): 'A' | 'B' | 'C' | 'D' | 'F' {
const avgFPS = this.getAverageFPS();
const p1FPS = this.getPercentile(1); // Worst 1% of frames
if (avgFPS >= 55 && p1FPS >= 30) return 'A';
if (avgFPS >= 45 && p1FPS >= 25) return 'B';
if (avgFPS >= 35 && p1FPS >= 20) return 'C';
if (avgFPS >= 25 && p1FPS >= 15) return 'D';
return 'F';
}
}
// React hook for animation performance monitoring
function useAnimationPerformance() {
const monitor = useRef(new AnimationPerformanceMonitor());
const [metrics, setMetrics] = useState({
averageFPS: 0,
grade: 'A' as const,
slowFrameCount: 0,
});
const startMonitoring = useCallback(() => {
let slowFrameCount = 0;
monitor.current.startMonitoring((fps) => {
slowFrameCount++;
console.warn(`⚠️ Slow animation frame: ${fps.toFixed(1)} FPS`);
});
// Update metrics every second
const interval = setInterval(() => {
setMetrics({
averageFPS: monitor.current.getAverageFPS(),
grade: monitor.current.getPerformanceGrade(),
slowFrameCount,
});
}, 1000);
return () => {
clearInterval(interval);
monitor.current.stopMonitoring();
};
}, []);
return {
startMonitoring,
metrics,
monitor: monitor.current,
};
}
// Performance-aware animation component
function MonitoredAnimation({ children }: { children: React.ReactNode }) {
const { startMonitoring, metrics } = useAnimationPerformance();
const [showWarning, setShowWarning] = useState(false);
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
const cleanup = startMonitoring();
return cleanup;
}
}, [startMonitoring]);
useEffect(() => {
// Show warning for poor performance
if (metrics.grade === 'D' || metrics.grade === 'F') {
setShowWarning(true);
const timer = setTimeout(() => setShowWarning(false), 3000);
return () => clearTimeout(timer);
}
}, [metrics.grade]);
return (
<div className="monitored-animation">
{children}
{process.env.NODE_ENV === 'development' && (
<div className="animation-metrics">
<div>FPS: {metrics.averageFPS.toFixed(1)}</div>
<div>Grade: {metrics.grade}</div>
</div>
)}
{showWarning && (
<div className="performance-warning">
⚠️ Animation performance is poor (Grade: {metrics.grade})
</div>
)}
</div>
);
}Frame Budget Management
// Frame budget manager for complex animations
class FrameBudgetManager {
private taskQueue: Array<() => Promise<void> | void> = [];
private isProcessing = false;
private frameStart = 0;
private readonly frameDeadline = 16; // 16ms for 60fps
private readonly safetyMargin = 2; // 2ms safety margin
scheduleTask(
task: () => Promise<void> | void,
priority: 'high' | 'normal' | 'low' = 'normal',
): void {
if (priority === 'high') {
this.taskQueue.unshift(task);
} else {
this.taskQueue.push(task);
}
if (!this.isProcessing) {
this.processQueue();
}
}
private processQueue = async (): Promise<void> => {
if (this.isProcessing || this.taskQueue.length === 0) return;
this.isProcessing = true;
this.frameStart = performance.now();
while (this.taskQueue.length > 0 && this.hasTimeRemaining()) {
const task = this.taskQueue.shift()!;
try {
await task();
} catch (error) {
console.error('Frame budget task error:', error);
}
}
this.isProcessing = false;
// Continue processing in next frame if tasks remain
if (this.taskQueue.length > 0) {
requestAnimationFrame(this.processQueue);
}
};
private hasTimeRemaining(): boolean {
const elapsed = performance.now() - this.frameStart;
return elapsed < this.frameDeadline - this.safetyMargin;
}
getRemainingTime(): number {
const elapsed = performance.now() - this.frameStart;
return Math.max(0, this.frameDeadline - this.safetyMargin - elapsed);
}
}
// React hook for frame budget management
function useFrameBudget() {
const budgetManager = useRef(new FrameBudgetManager());
const scheduleAnimation = useCallback(
(animationFn: () => Promise<void> | void, priority: 'high' | 'normal' | 'low' = 'normal') => {
budgetManager.current.scheduleTask(animationFn, priority);
},
[],
);
return { scheduleAnimation };
}
// Example usage in complex animation
function ComplexAnimationComponent() {
const { scheduleAnimation } = useFrameBudget();
const [items, setItems] = useState<Item[]>([]);
const animateItems = useCallback(async () => {
// High priority: Update visible items first
scheduleAnimation(() => {
setItems((prevItems) =>
prevItems.map((item) => ({
...item,
isVisible: true,
})),
);
}, 'high');
// Normal priority: Apply transforms
scheduleAnimation(async () => {
const elements = document.querySelectorAll('.animated-item');
for (const element of elements) {
(element as HTMLElement).style.transform = 'scale(1.05)';
// Yield control if we're running out of time
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
// Low priority: Update analytics
scheduleAnimation(() => {
// Track animation completion
analytics.track('animation_completed', {
itemCount: items.length,
});
}, 'low');
}, [items, scheduleAnimation]);
return (
<div>
<button onClick={animateItems}>Animate Items</button>
{items.map((item) => (
<div key={item.id} className="animated-item">
{item.content}
</div>
))}
</div>
);
}Common Animation Anti-Patterns
Avoiding Layout Thrashing
// ❌ Bad: Animating layout properties
function BadSlider({ items }: { items: Item[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
// This causes layout recalculation on every frame
const slideStyle = {
left: `-${currentIndex * 100}%`, // ❌ Animates 'left' property
transition: 'left 0.3s ease',
};
return (
<div className="slider-container">
<div className="slider-track" style={slideStyle}>
{items.map((item, index) => (
<div key={index} className="slide">
{item.content}
</div>
))}
</div>
</div>
);
}
// ✅ Good: Using transforms for hardware acceleration
function GoodSlider({ items }: { items: Item[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
// Transform only triggers compositing
const slideStyle = {
transform: `translateX(-${currentIndex * 100}%)`, // ✅ Uses transform
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
willChange: 'transform', // Hint to browser for optimization
};
return (
<div className="slider-container">
<div className="slider-track" style={slideStyle}>
{items.map((item, index) => (
<div key={index} className="slide">
{item.content}
</div>
))}
</div>
</div>
);
}
// ❌ Bad: Animating dimensions
function BadAccordion({ isExpanded }: { isExpanded: boolean }) {
const [height, setHeight] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current) {
// ❌ Reading scrollHeight causes layout
const scrollHeight = contentRef.current.scrollHeight;
setHeight(isExpanded ? scrollHeight : 0);
}
}, [isExpanded]);
return (
<div
className="accordion-content"
style={{
height, // ❌ Animating height causes layout thrashing
transition: 'height 0.3s ease',
overflow: 'hidden',
}}
ref={contentRef}
>
<div>Content that might be very long...</div>
</div>
);
}
// ✅ Good: Using transforms with fixed heights
function GoodAccordion({ isExpanded }: { isExpanded: boolean }) {
const [maxHeight, setMaxHeight] = useState<number>(0);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current && maxHeight === 0) {
// Measure once during mount
const rect = contentRef.current.getBoundingClientRect();
setMaxHeight(rect.height);
}
}, [maxHeight]);
return (
<div
className="accordion-container"
style={{
height: maxHeight,
overflow: 'hidden',
}}
>
<div
ref={contentRef}
className="accordion-content"
style={{
transform: isExpanded ? 'translateY(0)' : `translateY(-${maxHeight}px)`,
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
willChange: 'transform',
}}
>
<div>Content that might be very long...</div>
</div>
</div>
);
}Preventing Animation Blocking
// ❌ Bad: Synchronous state updates during animation
function BadAnimatedCounter({ target }: { target: number }) {
const [count, setCount] = useState(0);
useEffect(() => {
const animate = () => {
setCount((prevCount) => {
const newCount = prevCount + 1;
// ❌ Synchronous DOM update blocks animation
document.title = `Count: ${newCount}`;
if (newCount < target) {
// ❌ requestAnimationFrame with state update on every frame
requestAnimationFrame(animate);
}
return newCount;
});
};
animate();
}, [target]);
return <div className="counter">{count}</div>;
}
// ✅ Good: Optimized animation with minimal state updates
function GoodAnimatedCounter({ target }: { target: number }) {
const [displayCount, setDisplayCount] = useState(0);
const countRef = useRef(0);
const startTime = useRef(0);
const duration = 2000; // 2 seconds
useEffect(() => {
startTime.current = performance.now();
countRef.current = 0;
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime.current;
const progress = Math.min(elapsed / duration, 1);
// Easing function for smooth animation
const easedProgress = 1 - Math.pow(1 - progress, 3);
const currentCount = Math.round(easedProgress * target);
// Only update state when count actually changes
if (currentCount !== countRef.current) {
countRef.current = currentCount;
setDisplayCount(currentCount);
// ✅ Defer non-critical updates
if (currentCount % 10 === 0) {
setTimeout(() => {
document.title = `Count: ${currentCount}`;
}, 0);
}
}
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}, [target]);
return <div className="counter">{displayCount}</div>;
}
// ✅ Better: CSS-based counter animation
function CSSAnimatedCounter({ target }: { target: number }) {
const counterRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (counterRef.current) {
// Use CSS custom properties for animation
counterRef.current.style.setProperty('--target', target.toString());
counterRef.current.style.setProperty('--start', '0');
}
}, [target]);
return (
<div
ref={counterRef}
className="css-counter"
style={{
// CSS counter animation (hardware accelerated)
counterReset: 'counter calc(var(--start) * 1)',
animation: 'countUp 2s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
<style>{`
@keyframes countUp {
to {
counter-reset: counter calc(var(--target) * 1);
}
}
.css-counter::after {
content: counter(counter);
}
`}</style>
</div>
);
}Advanced Techniques
For more sophisticated animation patterns, including Intersection Observer optimization, Web Animations API integration, and comprehensive performance testing strategies, see Advanced Animation Performance Techniques.
Key advanced topics covered:
- Intersection Observer: Trigger animations only when elements are visible for better performance
- Web Animations API: Lower-level control for complex animation sequences
- Performance Testing: Automated testing frameworks for animation performance validation
- Advanced Optimization: GPU layer management and composite optimization