React Framer Motion: Animations and Transitions Guide
Framer Motion is the go-to animation library for React. It wraps every HTML and SVG element in a motion component that accepts declarative animation props — no hand-written keyframes, no imperative timeline juggling. From simple fade-ins to complex layout transitions and scroll-linked effects, Framer Motion handles it with a consistent, composable API that respects the user's reduced-motion preferences automatically.
Table of Contents
Motion Components and Basic Animations
Every HTML element has a motion counterpart — motion.div, motion.button, motion.svg, etc. Three props drive most animations: initial (starting state), animate (target state), and transition (how to get there). Values animate whenever the animate prop changes.
import { motion } from 'framer-motion'
// Simple fade + slide in
function FadeIn({ children }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
>
{children}
</motion.div>
)
}
// Animate on state change
function ToggleBox() {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(o => !o)}>Toggle</button>
<motion.div
animate={{ height: isOpen ? 200 : 0, opacity: isOpen ? 1 : 0 }}
transition={{ duration: 0.3 }}
style={{ overflow: 'hidden', background: '#1e293b', borderRadius: 8 }}
>
<p style={{ padding: '1rem' }}>Hidden content revealed!</p>
</motion.div>
</div>
)
}
// Loading spinner
function Spinner() {
return (
<motion.div
style={{ width: 40, height: 40, border: '4px solid #6366f1', borderTopColor: 'transparent', borderRadius: '50%' }}
animate={{ rotate: 360 }}
transition={{ duration: 0.8, repeat: Infinity, ease: 'linear' }}
/>
)
}
// Scale on mount
function Card({ children }) {
return (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
style={{ background: '#0d1424', borderRadius: 12, padding: '1.5rem' }}
>
{children}
</motion.div>
)
}
prefers-reduced-motion media query when you use the useReducedMotion hook to gate animations. Always provide reduced-motion fallbacks for accessibility.
Variants and Orchestration
Variants are named animation states defined as objects. You reference them by name in initial, animate, and exit. The real power is that variant names propagate down the component tree automatically — parent controls trigger children without passing props. staggerChildren and delayChildren orchestrate list animations with one line.
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1, // each child animates 100ms after the previous
delayChildren: 0.2, // wait 200ms before starting children
},
},
}
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { type: 'spring', stiffness: 300, damping: 24 },
},
}
function AnimatedList({ items }) {
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
>
{items.map(item => (
<motion.li key={item.id} variants={itemVariants}>
{item.label}
</motion.li>
))}
</motion.ul>
)
}
// Variant with animate controls
function HeroSection() {
const controls = useAnimationControls()
async function runSequence() {
await controls.start('slideIn')
await controls.start('pulse')
controls.start('rest')
}
const variants = {
slideIn: { x: 0, opacity: 1, transition: { duration: 0.5 } },
pulse: { scale: [1, 1.05, 1], transition: { duration: 0.3 } },
rest: { scale: 1 },
hidden: { x: -60, opacity: 0 },
}
return (
<motion.div variants={variants} initial="hidden" animate={controls}>
<button onClick={runSequence}>Animate</button>
</motion.div>
)
}
Gestures: Hover, Tap, Drag
Framer Motion exposes gesture props that animate in response to user interaction. whileHover, whileTap, and whileDrag define the state the element moves to while the gesture is active — no event listeners or state variables needed.
// Interactive button with hover + tap
function AnimatedButton({ children, onClick }) {
return (
<motion.button
onClick={onClick}
whileHover={{ scale: 1.05, boxShadow: '0 8px 32px rgba(99,102,241,0.4)' }}
whileTap={{ scale: 0.97 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
style={{
background: 'linear-gradient(135deg,#6366f1,#22d3ee)',
border: 'none', borderRadius: 50, padding: '0.6rem 1.4rem',
color: '#fff', fontWeight: 600, cursor: 'pointer',
}}
>
{children}
</motion.button>
)
}
// Draggable card with constraints
function DraggableCard() {
const constraintsRef = useRef(null)
return (
<div ref={constraintsRef} style={{ position: 'relative', height: 300, border: '1px dashed #374151' }}>
<motion.div
drag
dragConstraints={constraintsRef}
dragElastic={0.1}
whileDrag={{ scale: 1.05, boxShadow: '0 16px 40px rgba(0,0,0,0.4)' }}
style={{
width: 120, height: 80, background: '#0d1424',
border: '1px solid rgba(99,102,241,.4)', borderRadius: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'grab', userSelect: 'none',
}}
>
Drag me
</motion.div>
</div>
)
}
// Drag to reorder list
function SortableItem({ item, onDragEnd }) {
return (
<motion.li
layout
drag="y"
dragMomentum={false}
onDragEnd={onDragEnd}
whileDrag={{ zIndex: 10, scale: 1.02 }}
style={{ listStyle: 'none', padding: '0.75rem', background: '#0d1424',
marginBottom: 4, borderRadius: 8, cursor: 'grab' }}
>
{item.label}
</motion.li>
)
}
AnimatePresence and Exit Animations
AnimatePresence enables exit animations — React normally removes elements from the DOM immediately, but AnimatePresence delays removal until the exit animation completes. Wrap any conditionally rendered or list-mapped content to get smooth mount/unmount transitions.
import { AnimatePresence, motion } from 'framer-motion'
// Conditional rendering with exit animation
function Notification({ message, onDismiss }) {
return (
<AnimatePresence>
{message && (
<motion.div
key="notification"
initial={{ opacity: 0, x: 60 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 60, transition: { duration: 0.2 } }}
style={{
position: 'fixed', top: 20, right: 20,
background: '#0d1424', border: '1px solid rgba(99,102,241,.4)',
borderRadius: 8, padding: '0.75rem 1rem',
}}
>
{message}
<button onClick={onDismiss} style={{ marginLeft: 12 }}>✕</button>
</motion.div>
)}
</AnimatePresence>
)
}
// Page transitions with AnimatePresence + React Router
const pageVariants = {
initial: { opacity: 0, x: -20 },
animate: { opacity: 1, x: 0, transition: { duration: 0.35 } },
exit: { opacity: 0, x: 20, transition: { duration: 0.25 } },
}
function PageWrapper({ children }) {
const location = useLocation()
return (
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
>
{children}
</motion.div>
</AnimatePresence>
)
}
// Animated list with add/remove
function AnimatedTodoList({ todos, onRemove }) {
return (
<ul style={{ padding: 0 }}>
<AnimatePresence>
{todos.map(todo => (
<motion.li
key={todo.id}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25 }}
style={{ listStyle: 'none', overflow: 'hidden' }}
>
{todo.text}
<button onClick={() => onRemove(todo.id)}>Delete</button>
</motion.li>
))}
</AnimatePresence>
</ul>
)
}
Layout Animations
The layout prop is one of Framer Motion's most impressive features. Adding layout to a motion element makes it automatically animate its position and size whenever its layout changes — no animation code needed, no measuring DOM nodes manually.
// Shared layout animation with layoutId
function ImageGallery({ images }) {
const [selected, setSelected] = useState(null)
return (
<div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{images.map(image => (
<motion.img
key={image.id}
layoutId={`image-${image.id}`}
src={image.src}
onClick={() => setSelected(image)}
style={{ width: 100, height: 100, objectFit: 'cover', borderRadius: 8, cursor: 'pointer' }}
/>
))}
</div>
<AnimatePresence>
{selected && (
<motion.div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 100,
}}
onClick={() => setSelected(null)}
>
<motion.img
layoutId={`image-${selected.id}`}
src={selected.src}
style={{ maxWidth: '80vw', maxHeight: '80vh', borderRadius: 12 }}
/>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
// Auto-layout animation on reorder
function ReorderableList({ items }) {
return (
<ul>
{items.map(item => (
<motion.li key={item.id} layout transition={{ type: 'spring', damping: 25 }}>
{item.label}
</motion.li>
))}
</ul>
)
}
Scroll-Linked Animations
useScroll returns a scrollYProgress motion value between 0 and 1 that tracks scroll position. Combined with useTransform, you can drive any animatable property — opacity, scale, translateY, color — from the scroll position with zero event listeners and full 60fps performance.
import { useScroll, useTransform, useSpring } from 'framer-motion'
// Reading progress bar
function ReadingProgress() {
const { scrollYProgress } = useScroll()
const scaleX = useSpring(scrollYProgress, { stiffness: 100, damping: 30 })
return (
<motion.div
style={{
position: 'fixed', top: 0, left: 0, right: 0, height: 3,
background: 'linear-gradient(90deg,#6366f1,#22d3ee)',
transformOrigin: '0%',
scaleX,
zIndex: 999,
}}
/>
)
}
// Parallax section
function ParallaxHero() {
const ref = useRef(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ['start start', 'end start'] })
const y = useTransform(scrollYProgress, [0, 1], ['0%', '50%'])
const opacity = useTransform(scrollYProgress, [0, 0.8], [1, 0])
return (
<section ref={ref} style={{ height: '100vh', overflow: 'hidden', position: 'relative' }}>
<motion.div style={{ y, opacity, position: 'absolute', inset: 0 }}>
<img src="/hero-bg.jpg" style={{ width: '100%', height: '120%', objectFit: 'cover' }} />
</motion.div>
<div style={{ position: 'relative', zIndex: 1, padding: '10rem 2rem' }}>
<h1>Scroll to reveal</h1>
</div>
</section>
)
}
// Animate element when it enters viewport
function AnimateOnScroll({ children }) {
const ref = useRef(null)
const isInView = useInView(ref, { once: true, margin: '-100px' })
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 40 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, ease: 'easeOut' }}
>
{children}
</motion.div>
)
}
Spring and Keyframe Transitions
Framer Motion supports three transition types: tween (duration-based with easing), spring (physics-based, feels natural), and inertia (decelerates from a velocity). For complex multi-step animations, pass an array as the animation value to define keyframes.
// Spring physics — feels organic
const springConfig = {
type: 'spring',
stiffness: 260, // how stiff the spring is (higher = snappier)
damping: 20, // how quickly it stops oscillating
mass: 0.8, // heavier = slower
}
<motion.div animate={{ x: 100 }} transition={springConfig} />
// Keyframe animation — bounce sequence
<motion.div
animate={{ y: [0, -30, 0, -15, 0] }}
transition={{ duration: 0.8, times: [0, 0.3, 0.5, 0.7, 1], ease: 'easeInOut' }}
/>
// Color keyframes
<motion.div
animate={{ backgroundColor: ['#6366f1', '#22d3ee', '#f59e0b', '#6366f1'] }}
transition={{ duration: 3, repeat: Infinity, ease: 'linear' }}
/>
// Stagger with spring
const list = {
visible: {
transition: { staggerChildren: 0.07, delayChildren: 0.1 }
},
hidden: {}
}
const item = {
visible: { y: 0, opacity: 1, transition: { type: 'spring', stiffness: 300, damping: 24 } },
hidden: { y: 20, opacity: 0 }
}
// useMotionValue for imperative control
function DragProgress() {
const x = useMotionValue(0)
const background = useTransform(x, [-200, 0, 200], ['#ef4444', '#0d1424', '#22d3ee'])
return (
<motion.div
drag="x"
dragConstraints={{ left: -200, right: 200 }}
style={{ x, background, width: 80, height: 80, borderRadius: '50%', cursor: 'grab' }}
/>
)
}
Performance and Best Practices
Framer Motion animations run on the compositor thread by default when you animate transform and opacity — this means 60fps with zero layout reflow. Avoid animating layout-triggering properties like width, height, top, or left in hot-path animations. Use scaleX/scaleY instead of width/height, and x/y instead of left/top.
// Good — GPU composited, no layout reflow
<motion.div animate={{ x: 100, opacity: 0.5, scale: 1.1 }} />
// Avoid — triggers layout recalculation on every frame
<motion.div animate={{ left: 100, width: 200 }} /> // BAD
// Reduce bundle size — import only what you need
import { motion, AnimatePresence } from 'framer-motion' // full bundle ~50KB gzip
// OR use the slim build for basic animations:
import { m, LazyMotion, domAnimation } from 'framer-motion'
function App() {
return (
<LazyMotion features={domAnimation}>
<m.div animate={{ x: 100 }} /> {/* uses m instead of motion */}
</LazyMotion>
)
}
// Respect reduced motion
import { useReducedMotion } from 'framer-motion'
function AnimatedCard({ children }) {
const prefersReducedMotion = useReducedMotion()
return (
<motion.div
initial={{ opacity: 0, y: prefersReducedMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.4 }}
>
{children}
</motion.div>
)
}
// Don't animate on every render — memoize variants
const cardVariants = {
hidden: { opacity: 0, scale: 0.95 },
visible: { opacity: 1, scale: 1 },
}
// Define OUTSIDE component — stable object reference, no re-creation on render
function Card() {
return <motion.div variants={cardVariants} initial="hidden" animate="visible" />
}