optimal architecture for React animations separates animation triggers from animation execution. We recommend a hybrid strategy: CSS for static transitions and a custom useFlip hook for dynamic layout changes, backed by the Web Animations API (WAAPI).
Architecture Decisions
- Compositor-First Principle: All animations must target
transform and opacity. This promotes layers to the GPU compositor, avoiding layout and paint operations on the main thread.
useLayoutEffect for DOM Reads: To prevent visual flicker, DOM measurements must occur synchronously after DOM mutations but before the browser paints. useLayoutEffect is mandatory for FLIP implementations.
- WAAPI over
requestAnimationFrame: The Web Animations API provides a native, optimized way to handle animations without manual rAF loops, offering better integration with browser dev tools and automatic cleanup.
Implementation: The useFlip Hook
This hook implements the FLIP technique for elements whose position or size changes due to React state updates. It calculates the delta between the previous and current layout and applies an inverse transform that animates to identity.
import { useRef, useEffect, useLayoutEffect, RefObject } from 'react';
interface FlipOptions {
duration?: number;
easing?: string;
}
/**
* Applies FLIP animation to a DOM element when dependencies change.
* Captures the element's bounds before the render, then animates
* from the inverted position to the new position after render.
*/
export function useFlip<T extends HTMLElement>(
ref: RefObject<T>,
deps: unknown[],
options: FlipOptions = {}
) {
const { duration = 300, easing = 'cubic-bezier(0.4, 0, 0.2, 1)' } = options;
const prevBounds = useRef<DOMRect | null>(null);
// 1. FIRST: Read current bounds synchronously before paint
useLayoutEffect(() => {
if (ref.current) {
prevBounds.current = ref.current.getBoundingClientRect();
}
});
// 2. LAST, INVERT, PLAY: Animate after React commits changes
useEffect(() => {
const currentEl = ref.current;
if (!currentEl || !prevBounds.current) return;
const newBounds = currentEl.getBoundingClientRect();
const oldBounds = prevBounds.current;
// Calculate deltas
const deltaX = oldBounds.left - newBounds.left;
const deltaY = oldBounds.top - newBounds.top;
const scaleDeltaX = oldBounds.width / newBounds.width;
const scaleDeltaY = oldBounds.height / newBounds.height;
// Check if movement actually occurred
if (
Math.abs(deltaX) < 0.1 &&
Math.abs(deltaY) < 0.1 &&
Math.abs(scaleDeltaX - 1) < 0.01 &&
Math.abs(scaleDeltaY - 1) < 0.01
) {
return;
}
// INVERT: Apply inverse transform immediately
currentEl.style.transformOrigin = '0 0';
currentEl.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${scaleDeltaX}, ${scaleDeltaY})`;
// PLAY: Animate to identity
// Force reflow to ensure the browser registers the initial transform
void currentEl.offsetWidth;
const animation = currentEl.animate(
[
{ transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleDeltaX}, ${scaleDeltaY})` },
{ transform: 'translate(0, 0) scale(1, 1)' }
],
{
duration,
easing,
fill: 'forwards',
}
);
animation.onfinish = () => {
// Cleanup: Remove inline styles to allow CSS to take over
if (currentEl) {
currentEl.style.transform = '';
currentEl.style.transformOrigin = '';
}
};
return () => {
animation.cancel();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
Usage Pattern
Integrate the hook into components where layout shifts are expected, such as expanding cards or list reordering.
import { useState } from 'react';
import { useFlip } from './hooks/useFlip';
export function ExpandableCard({ id, isExpanded }: { id: string; isExpanded: boolean }) {
const cardRef = useRef<HTMLDivElement>(null);
// Trigger FLIP when isExpanded changes
useFlip(cardRef, [isExpanded]);
return (
<div
ref={cardRef}
className={`card ${isExpanded ? 'expanded' : 'collapsed'}`}
style={{ willChange: 'transform' }} // Promote to compositor
>
{/* Content */}
</div>
);
}
Rationale:
- Zero Dependencies: Removes the need for heavy libraries for layout transitions.
- Safe Cleanup: The animation cancellation in the cleanup function prevents memory leaks and race conditions during rapid state changes.
- Performance:
willChange: transform hints the browser to create a dedicated layer, ensuring the animation runs on the compositor thread.
Pitfall Guide
Production animations fail due to specific, recurring architectural errors. Avoid these pitfalls to ensure stability.
-
Animating Layout Properties via JS
- Mistake: Using
requestAnimationFrame to interpolate width, height, top, or left.
- Impact: Forces layout recalculation on every frame. On mobile devices, this drops FPS below 30.
- Fix: Always animate
transform and opacity. Use FLIP for layout changes.
-
The useEffect Timing Trap
- Mistake: Reading DOM dimensions inside
useEffect for animation calculations.
- Impact: React may have already painted the new state before the effect runs, causing a visual "flash" where the element jumps to the new position before animating.
- Fix: Use
useLayoutEffect for all DOM reads required for animation math.
-
Missing Stable Keys in Lists
- Mistake: Using array indices as keys in animated lists.
- Impact: React unmounts and remounts components during reordering, destroying animation state. Shared element transitions break entirely.
- Fix: Use unique, stable identifiers. Ensure the key remains constant across re-renders.
-
Ignoring prefers-reduced-motion
- Mistake: Hardcoding animation durations and easing functions without accessibility checks.
- Impact: Violates WCAG 2.1 guidelines. Causes vestibular disorders for sensitive users.
- Fix: Implement a media query hook to disable or simplify animations based on user preference.
export function useReducedMotion() {
const [isReduced, setIsReduced] = useState(
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => setIsReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return isReduced;
}
-
State Thrashing During Animation
- Mistake: Updating state faster than the animation duration completes.
- Impact: Animations interrupt each other, causing jitter or snapping.
- Fix: Implement animation locks or debounce rapid state updates. Use
animation.onfinish to gate state changes where necessary.
-
Z-Index Stacking Context Violations
- Mistake: Animating elements that create new stacking contexts (e.g.,
transform or opacity less than 1) without managing z-index.
- Impact: Animated elements may appear behind static content unexpectedly.
- Fix: Explicitly manage
z-index for animated layers. Test animations in isolation and within complex DOM hierarchies.
-
Bundle Bloat from Partial Imports
- Mistake: Importing entire animation libraries when only specific utilities are needed.
- Impact: Increased initial load time and parsing overhead.
- Fix: Use tree-shakeable imports. Prefer modular libraries like
motion (Motion One) or implement custom hooks for simple cases.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Hover/Focus States | CSS Transitions | GPU accelerated, zero JS overhead. | Free |
| List Reordering | useFlip Hook or Motion One | FLIP handles layout math efficiently; Motion One offers lightweight choreography. | Dev time / ~5KB |
| Shared Element Transition | Custom FLIP Hook | Libraries often lack flexibility for complex shared elements. Custom hook ensures precision. | Dev time |
| Complex Choreography | Motion One | Declarative API with tree-shaking support. Better DX than raw FLIP for multi-element sequences. | ~5-10KB |
| Physics-Based Motion | React Spring | Spring physics require iterative solvers; library handles math and performance optimization. | ~25KB |
| Lottie/Rive Assets | @lottiefiles/lottie-player or Rive | Vector animations require specialized renderers. Do not implement manually. | Asset size + ~15KB |
Configuration Template
Standardize animation parameters to ensure consistency and maintainability. Create a central configuration file.
// config/motion.config.ts
export const motionConfig = {
durations: {
instant: 150,
fast: 250,
normal: 350,
slow: 500,
},
easings: {
easeOut: 'cubic-bezier(0.0, 0.0, 0.2, 1)',
easeIn: 'cubic-bezier(0.4, 0.0, 1, 1)',
spring: 'cubic-bezier(0.175, 0.885, 0.32, 1.275)',
},
// Reduced motion overrides
reducedMotion: {
duration: 0,
easing: 'linear',
},
};
// Utility to apply config respecting user preference
import { useReducedMotion } from '../hooks/useReducedMotion';
export function getMotionProps(baseProps: any) {
const isReduced = useReducedMotion();
if (isReduced) {
return {
...baseProps,
duration: motionConfig.reducedMotion.duration,
easing: motionConfig.reducedMotion.easing,
};
}
return baseProps;
}
Quick Start Guide
- Install Core Dependencies:
npm install motion
# OR for zero-dependency FLIP, no install required.
- Create Animation Config:
Copy the
motion.config.ts template to your project. Implement the useReducedMotion hook.
- Refactor Simple Transitions:
Replace inline style toggles with CSS classes using
transition for transform and opacity.
- Implement FLIP for Layout Shifts:
Add the
useFlip hook to components with dynamic sizing or positioning. Pass stable dependencies to trigger animations.
- Verify and Deploy:
Run
npm run build and analyze bundle size. Test animations on a throttled network and low-end device profile. Ensure accessibility checks pass.