CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-react-motion

A spring that solves your animation problems.

Pending
Overview
Eval results
Files

staggered-motion.mddocs/

StaggeredMotion Component

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.

Capabilities

StaggeredMotion Component

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>
  );
}

defaultStyles Property

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>;

styles Property

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>;

children Property

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;

Animation Behavior

Cascading Logic

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)};
  }
})}

Staggering Patterns

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)
}))}

Performance Considerations

  • Each element depends on previous calculations, so changes cascade through the chain
  • Longer chains may have slight performance impact
  • Animation stops when all elements reach their targets

Common Patterns

Mouse Following Chain

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>
  );
}

Accordion Effect

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>
  );
}

Loading Dots

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

docs

index.md

motion.md

spring-system.md

staggered-motion.md

transition-motion.md

tile.json