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 motionconst gentle = { type: 'spring', stiffness: 100, damping: 15 };
// Bouncy (200-400): Playful, energeticconst bouncy = { type: 'spring', stiffness: 300, damping: 20 };
// Snappy (400+): Quick, responsiveconst snappy = { type: 'spring', stiffness: 500, damping: 30 };Damping
Controls how much the spring oscillates:
// Low damping (10-15): More bounceconst bouncy = { stiffness: 300, damping: 12 }; // Overshoots
// Medium damping (20-25): Subtle bounceconst balanced = { stiffness: 300, damping: 22 }; // Perfect
// High damping (30+): No bounceconst smooth = { stiffness: 300, damping: 35 }; // Smooth arrivalMass
Affects how heavy the element feels:
// Light (0.1-0.5): Quick, nimbleconst light = { stiffness: 300, damping: 20, mass: 0.3 };
// Medium (0.5-1): Standard weightconst medium = { stiffness: 300, damping: 20, mass: 0.8 };
// Heavy (1+): Slow, weightyconst heavy = { stiffness: 300, damping: 20, mass: 1.5 };Creating a Reusable Animation System
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 animationsexport 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 animationsexport 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! ✨