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! ✨