Back to blog

Mastering Framer Motion: Spring Physics for Natural Animations

#react#animation#framer-motion#ux

Mastering Framer Motion: Spring Physics for Natural Animations

Linear animations feel robotic. Spring-based animations feel natural. Here's how to master Framer Motion's spring physics to create delightful user experiences.

Why Spring Animations?

Spring animations mimic real-world physics. Instead of moving at a constant speed, elements accelerate and decelerate naturally, often with a subtle bounce.

Compare:

// ❌ Linear - feels mechanical
<motion.div
  animate={{ x: 100 }}
  transition={{ duration: 0.3, ease: 'linear' }}
/>

// ✅ Spring - feels natural
<motion.div
  animate={{ x: 100 }}
  transition={{ type: 'spring', stiffness: 300, damping: 20 }}
/>

Understanding Spring Parameters

Stiffness

Controls how quickly the spring tries to reach its target:

// Gentle (100-200): Slow, smooth motion
const gentle = { type: 'spring', stiffness: 100, damping: 15 };

// Bouncy (200-400): Playful, energetic
const bouncy = { type: 'spring', stiffness: 300, damping: 20 };

// Snappy (400+): Quick, responsive
const snappy = { type: 'spring', stiffness: 500, damping: 30 };

Damping

Controls how much the spring oscillates:

// Low damping (10-15): More bounce
const bouncy = { stiffness: 300, damping: 12 }; // Overshoots

// Medium damping (20-25): Subtle bounce
const balanced = { stiffness: 300, damping: 22 }; // Perfect

// High damping (30+): No bounce
const smooth = { stiffness: 300, damping: 35 }; // Smooth arrival

Mass

Affects how heavy the element feels:

// Light (0.1-0.5): Quick, nimble
const light = { stiffness: 300, damping: 20, mass: 0.3 };

// Medium (0.5-1): Standard weight
const medium = { stiffness: 300, damping: 20, mass: 0.8 };

// Heavy (1+): Slow, weighty
const heavy = { stiffness: 300, damping: 20, mass: 1.5 };

Creating a Reusable Animation System

// lib/animations.ts
export const springs = {
  // Entrance animations - gentle and welcoming
  gentle: {
    type: 'spring' as const,
    stiffness: 100,
    damping: 15,
    mass: 0.5,
  },

  // Interactive elements - responsive and playful
  bouncy: {
    type: 'spring' as const,
    stiffness: 300,
    damping: 20,
    mass: 0.8,
  },

  // Micro-interactions - quick and snappy
  snappy: {
    type: 'spring' as const,
    stiffness: 400,
    damping: 30,
    mass: 0.5,
  },

  // Heavy elements - dramatic and weighty
  heavy: {
    type: 'spring' as const,
    stiffness: 200,
    damping: 25,
    mass: 1.2,
  },
} as const;

// Entrance animations
export const fadeInUp = {
  hidden: { opacity: 0, y: 20 },
  visible: { opacity: 1, y: 0, transition: springs.gentle },
};

export const fadeInScale = {
  hidden: { opacity: 0, scale: 0.9 },
  visible: { opacity: 1, scale: 1, transition: springs.gentle },
};

// Hover animations
export const hoverLift = {
  rest: { y: 0 },
  hover: { y: -4, transition: springs.snappy },
};

export const hoverScale = {
  rest: { scale: 1 },
  hover: { scale: 1.05, transition: springs.bouncy },
};

Advanced Patterns

Orchestrating Animations

import { motion } from 'framer-motion';

const container = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      // Stagger children with delay
      staggerChildren: 0.1,
      delayChildren: 0.2,
    },
  },
};

const item = {
  hidden: { opacity: 0, y: 20 },
  visible: { opacity: 1, y: 0 },
};

export function StaggeredList({ items }: { items: string[] }) {
  return (
    <motion.ul
      variants={container}
      initial="hidden"
      animate="visible"
    >
      {items.map((item, i) => (
        <motion.li key={i} variants={item}>
          {item}
        </motion.li>
      ))}
    </motion.ul>
  );
}

Gesture-Based Animations

export function DraggableCard() {
  return (
    <motion.div
      drag
      dragConstraints={{ left: 0, right: 300, top: 0, bottom: 300 }}
      dragElastic={0.1}
      whileDrag={{ scale: 1.05, cursor: 'grabbing' }}
      whileHover={{ scale: 1.02 }}
      whileTap={{ scale: 0.98 }}
      transition={springs.bouncy}
    >
      Drag me!
    </motion.div>
  );
}

Scroll-Triggered Animations

import { useScroll, useTransform, motion } from 'framer-motion';

export function ParallaxSection() {
  const { scrollYProgress } = useScroll();

  const y = useTransform(scrollYProgress, [0, 1], ['0%', '50%']);
  const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [1, 0.5, 0]);

  return (
    <motion.div style={{ y, opacity }}>
      <h2>Parallax Content</h2>
    </motion.div>
  );
}

Layout Animations

export function ExpandableCard() {
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <motion.div
      layout // Automatically animates layout changes
      onClick={() => setIsExpanded(!isExpanded)}
      transition={springs.gentle}
      style={{
        borderRadius: 16,
        padding: 20,
        backgroundColor: '#1a1a1a',
      }}
    >
      <motion.h3 layout="position">Title</motion.h3>
      {isExpanded && (
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
        >
          <p>Expanded content here...</p>
        </motion.div>
      )}
    </motion.div>
  );
}

Performance Optimization

1. Use GPU-Accelerated Properties

// ✅ GPU-accelerated (smooth)
<motion.div
  animate={{ x: 100, y: 100, scale: 1.2, rotate: 45, opacity: 0.8 }}
/>

// ❌ CPU-bound (janky)
<motion.div
  animate={{ width: 200, height: 200, backgroundColor: '#ff0000' }}
/>

2. Batch Animations

// ❌ Multiple renders
<motion.div animate={{ x: 100 }} />
<motion.div animate={{ y: 100 }} />

// ✅ Single render
<motion.div animate={{ x: 100, y: 100 }} />

3. Use will-change Sparingly

<motion.div
  style={{ willChange: 'transform' }} // Only for elements that animate frequently
  whileHover={{ scale: 1.05 }}
/>

4. Reduce Motion for Accessibility

import { useReducedMotion } from 'framer-motion';

export function AccessibleAnimation() {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={shouldReduceMotion ? { duration: 0.01 } : springs.gentle}
    >
      Content
    </motion.div>
  );
}

Real-World Examples

Card with Hover Effects

export function ProjectCard({ title, description }: CardProps) {
  return (
    <motion.div
      className="card"
      initial="rest"
      whileHover="hover"
      whileTap="tap"
    >
      <motion.div
        variants={{
          rest: { y: 0, boxShadow: '0 4px 6px rgba(0,0,0,0.1)' },
          hover: {
            y: -8,
            boxShadow: '0 20px 40px rgba(0,0,0,0.2)',
            transition: springs.snappy,
          },
          tap: { scale: 0.98 },
        }}
      >
        <h3>{title}</h3>
        <p>{description}</p>
      </motion.div>
    </motion.div>
  );
}

Loading Skeleton

export function Skeleton() {
  return (
    <motion.div
      className="skeleton"
      animate={{
        backgroundColor: ['#1a1a1a', '#2a2a2a', '#1a1a1a'],
      }}
      transition={{
        duration: 1.5,
        repeat: Infinity,
        ease: 'easeInOut',
      }}
    />
  );
}

Page Transitions

import { AnimatePresence } from 'framer-motion';

export function PageTransition({ children }: { children: React.ReactNode }) {
  return (
    <AnimatePresence mode="wait">
      <motion.div
        initial={{ opacity: 0, x: -20 }}
        animate={{ opacity: 1, x: 0 }}
        exit={{ opacity: 0, x: 20 }}
        transition={springs.gentle}
      >
        {children}
      </motion.div>
    </AnimatePresence>
  );
}

Debugging Tips

Visualize Springs

import { useSpring } from 'framer-motion';

export function SpringDebugger() {
  const x = useSpring(0, { stiffness: 300, damping: 20 });

  return (
    <div>
      <button onClick={() => x.set(100)}>Animate</button>
      <motion.div style={{ x }} />
      <pre>Current value: {x.get()}</pre>
    </div>
  );
}

Performance Monitoring

import { useEffect } from 'react';
import { useAnimationFrame } from 'framer-motion';

export function FPSCounter() {
  const [fps, setFps] = useState(60);
  let lastTime = performance.now();
  let frames = 0;

  useAnimationFrame(() => {
    frames++;
    const currentTime = performance.now();

    if (currentTime >= lastTime + 1000) {
      setFps(frames);
      frames = 0;
      lastTime = currentTime;
    }
  });

  return <div>FPS: {fps}</div>;
}

Conclusion

Mastering spring animations takes practice, but the results are worth it:

  • Natural motion that feels alive
  • Delightful interactions that engage users
  • Professional polish that sets your app apart

Start with the spring presets, experiment with parameters, and always test with prefers-reduced-motion for accessibility.

Happy animating! ✨