A spring that solves your animation problems.
—
The StaggeredMotion component creates cascading animations where each element's motion depends on the state of previous elements. This creates natural-looking staggered effects, perfect for list transitions, domino effects, and sequential animations.
Creates multiple animated elements where each element's target style can depend on the previous elements' current interpolated styles.
/**
* Multiple element animation where each element depends on previous ones
* Perfect for cascading effects and staggered list transitions
*/
class StaggeredMotion extends React.Component {
static propTypes: {
/** Initial style values for all elements (optional) */
defaultStyles?: Array<PlainStyle>,
/** Function returning target styles based on previous element states (required) */
styles: (previousInterpolatedStyles: ?Array<PlainStyle>) => Array<Style>,
/** Render function receiving array of interpolated styles (required) */
children: (interpolatedStyles: Array<PlainStyle>) => ReactElement
}
}Usage Examples:
import React, { useState } from 'react';
import { StaggeredMotion, spring } from 'react-motion';
// Basic staggered animation
function StaggeredList() {
const [mouseX, setMouseX] = useState(0);
return (
<StaggeredMotion
defaultStyles={[{x: 0}, {x: 0}, {x: 0}, {x: 0}]}
styles={prevInterpolatedStyles => prevInterpolatedStyles.map((_, i) => {
return i === 0
? {x: spring(mouseX)}
: {x: spring(prevInterpolatedStyles[i - 1].x)};
})}
>
{interpolatedStyles => (
<div
onMouseMove={e => setMouseX(e.clientX)}
style={{height: '400px', background: '#f0f0f0'}}
>
{interpolatedStyles.map((style, i) => (
<div
key={i}
style={{
position: 'absolute',
transform: `translateX(${style.x}px) translateY(${i * 50}px)`,
width: '40px',
height: '40px',
background: `hsl(${i * 60}, 70%, 50%)`,
borderRadius: '20px'
}}
/>
))}
</div>
)}
</StaggeredMotion>
);
}
// Staggered list items
function StaggeredListItems() {
const [items, setItems] = useState(['Item 1', 'Item 2', 'Item 3']);
const [expanded, setExpanded] = useState(false);
return (
<div>
<button onClick={() => setExpanded(!expanded)}>
Toggle List
</button>
<StaggeredMotion
defaultStyles={items.map(() => ({opacity: 0, y: -20}))}
styles={prevInterpolatedStyles =>
prevInterpolatedStyles.map((_, i) => {
const prevStyle = i === 0 ? null : prevInterpolatedStyles[i - 1];
const baseDelay = expanded ? 0 : 1;
const followDelay = prevStyle ? prevStyle.opacity * 0.8 : baseDelay;
return {
opacity: spring(expanded ? 1 : 0),
y: spring(expanded ? 0 : -20, {
stiffness: 120,
damping: 17
})
};
})
}
>
{interpolatedStyles => (
<div>
{interpolatedStyles.map((style, i) => (
<div
key={i}
style={{
opacity: style.opacity,
transform: `translateY(${style.y}px)`,
padding: '10px',
margin: '5px 0',
background: 'white',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
{items[i]}
</div>
))}
</div>
)}
</StaggeredMotion>
</div>
);
}
// Pendulum chain effect
function PendulumChain() {
const [angle, setAngle] = useState(0);
return (
<StaggeredMotion
defaultStyles={new Array(5).fill({rotate: 0})}
styles={prevInterpolatedStyles => prevInterpolatedStyles.map((_, i) => {
return i === 0
? {rotate: spring(angle)}
: {rotate: spring(prevInterpolatedStyles[i - 1].rotate * 0.8)};
})}
>
{interpolatedStyles => (
<div style={{padding: '50px'}}>
<button onClick={() => setAngle(angle === 0 ? 45 : 0)}>
Swing Pendulum
</button>
<div style={{position: 'relative', height: '300px'}}>
{interpolatedStyles.map((style, i) => (
<div
key={i}
style={{
position: 'absolute',
left: `${50 + i * 40}px`,
top: '50px',
width: '2px',
height: '150px',
background: '#333',
transformOrigin: 'top center',
transform: `rotate(${style.rotate}deg)`
}}
>
<div
style={{
position: 'absolute',
bottom: '-10px',
left: '-8px',
width: '16px',
height: '16px',
background: '#e74c3c',
borderRadius: '50%'
}}
/>
</div>
))}
</div>
</div>
)}
</StaggeredMotion>
);
}Optional array of initial style values for all animated elements. If omitted, initial values are extracted from the styles function.
/**
* Initial style values for all elements
* Array length determines number of animated elements
*/
defaultStyles?: Array<PlainStyle>;Function that receives the previous frame's interpolated styles and returns an array of target styles. This is where the staggering logic is implemented.
/**
* Function returning target styles based on previous element states
* Called every frame with current interpolated values
* @param previousInterpolatedStyles - Current values from previous frame
* @returns Array of target Style objects
*/
styles: (previousInterpolatedStyles: ?Array<PlainStyle>) => Array<Style>;Render function that receives the current interpolated styles for all elements and returns a React element.
/**
* Render function receiving array of interpolated styles
* Called on every animation frame with current values for all elements
*/
children: (interpolatedStyles: Array<PlainStyle>) => ReactElement;The key to StaggeredMotion is in the styles function:
styles={prevInterpolatedStyles => prevInterpolatedStyles.map((_, i) => {
if (i === 0) {
// First element follows external state
return {x: spring(targetValue)};
} else {
// Subsequent elements follow previous element
return {x: spring(prevInterpolatedStyles[i - 1].x)};
}
})}Linear Following: Each element directly follows the previous
styles={prev => prev.map((_, i) =>
i === 0
? {x: spring(leader)}
: {x: spring(prev[i - 1].x)}
)}Delayed Following: Add delay or damping to the chain
styles={prev => prev.map((_, i) =>
i === 0
? {x: spring(leader)}
: {x: spring(prev[i - 1].x * 0.8)} // 80% of previous
)}Wave Effects: Use mathematical functions for wave-like motion
styles={prev => prev.map((_, i) => ({
y: spring(Math.sin(time + i * 0.5) * amplitude)
}))}function MouseChain() {
const [mouse, setMouse] = useState({x: 0, y: 0});
return (
<StaggeredMotion
defaultStyles={new Array(10).fill({x: 0, y: 0})}
styles={prev => prev.map((_, i) =>
i === 0
? {x: spring(mouse.x), y: spring(mouse.y)}
: {
x: spring(prev[i - 1].x, {stiffness: 300, damping: 30}),
y: spring(prev[i - 1].y, {stiffness: 300, damping: 30})
}
)}
>
{styles => (
<div
onMouseMove={e => setMouse({x: e.clientX, y: e.clientY})}
style={{height: '100vh', background: '#000'}}
>
{styles.map((style, i) => (
<div
key={i}
style={{
position: 'absolute',
left: style.x,
top: style.y,
width: 20 - i,
height: 20 - i,
background: `hsl(${i * 30}, 70%, 50%)`,
borderRadius: '50%',
transform: 'translate(-50%, -50%)'
}}
/>
))}
</div>
)}
</StaggeredMotion>
);
}function StaggeredAccordion() {
const [openIndex, setOpenIndex] = useState(null);
const items = ['Section 1', 'Section 2', 'Section 3', 'Section 4'];
return (
<StaggeredMotion
defaultStyles={items.map(() => ({height: 40, opacity: 1}))}
styles={prev => prev.map((_, i) => {
const isOpen = openIndex === i;
const prevOpen = i > 0 && prev[i - 1].height > 40;
return {
height: spring(isOpen ? 200 : 40),
opacity: spring(isOpen || !prevOpen ? 1 : 0.6)
};
})}
>
{styles => (
<div>
{styles.map((style, i) => (
<div
key={i}
style={{
height: style.height,
opacity: style.opacity,
background: '#f8f9fa',
border: '1px solid #dee2e6',
margin: '2px 0',
overflow: 'hidden',
cursor: 'pointer'
}}
onClick={() => setOpenIndex(openIndex === i ? null : i)}
>
<div style={{padding: '10px', fontWeight: 'bold'}}>
{items[i]}
</div>
{style.height > 40 && (
<div style={{padding: '0 10px 10px'}}>
Content for {items[i]}...
</div>
)}
</div>
))}
</div>
)}
</StaggeredMotion>
);
}function LoadingDots() {
const [time, setTime] = useState(0);
React.useEffect(() => {
const interval = setInterval(() => {
setTime(t => t + 0.1);
}, 16);
return () => clearInterval(interval);
}, []);
return (
<StaggeredMotion
defaultStyles={[{y: 0}, {y: 0}, {y: 0}]}
styles={prev => prev.map((_, i) => ({
y: spring(Math.sin(time + i * 0.8) * 10)
}))}
>
{styles => (
<div style={{display: 'flex', gap: '8px', padding: '20px'}}>
{styles.map((style, i) => (
<div
key={i}
style={{
width: '12px',
height: '12px',
background: '#007bff',
borderRadius: '50%',
transform: `translateY(${style.y}px)`
}}
/>
))}
</div>
)}
</StaggeredMotion>
);
}Install with Tessl CLI
npx tessl i tessl/npm-react-motion