framer-motion
Motion (Framer Motion) — Animation Skill
Production-grade animation patterns for React and Next.js. This skill helps you write correct, performant, accessible animations using the Motion library (v12+).
Imports
Motion rebranded from framer-motion to motion. Both package names work, but the import path matters:
// Client Components (standard React)
import { motion, AnimatePresence } from "motion/react"
// Next.js Server Components — use the client export
import * as motion from "motion/react-client"
// Legacy import (still works with the framer-motion package)
import { motion } from "framer-motion"
If the project already uses framer-motion as a dependency, keep using "framer-motion" imports for consistency. Don't mix import sources.
Core Concepts
motion.* Components
Every HTML/SVG element has a motion counterpart. These accept animation props:
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
/>
Important: motion.* components are client components. In Next.js App Router, either mark the file "use client" or wrap them in a client component.
MotionValues — Animate Without Re-renders
useMotionValue creates values that update without triggering React re-renders. This is the key to 60fps animations:
const x = useMotionValue(0)
const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0])
return <motion.div style={{ x, opacity }} drag="x" />
Rules:
- Use
useMotionValuefor any value that changes every frame (scroll position, drag position, continuous animation) - Use
useTransformto derive values from other MotionValues (no re-renders) - Never use
useStatefor frame-by-frame updates — it causes re-renders on every frame
Transitions
Control how animations move between states:
// Spring (default for physical properties like x, y, scale)
transition={{ type: "spring", stiffness: 300, damping: 30 }}
// Tween (default for non-physical properties like opacity, color)
transition={{ type: "tween", duration: 0.3, ease: "easeInOut" }}
// Custom cubic bezier
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
Common Patterns
Scroll-Triggered Fade-In
The most common animation pattern. Use whileInView — do NOT manually use IntersectionObserver:
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.1 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
>
{children}
</motion.div>
viewport.once: true— only animate on first entry (standard for marketing pages)viewport.amount— how much of the element must be visible (0.1 = 10%)viewport.margin— extend the trigger area (e.g.,"0px 0px 50px 0px")
Staggered Children
Animate children one after another using variants:
const container = {
hidden: {},
show: { transition: { staggerChildren: 0.1 } },
}
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
}
<motion.ul variants={container} initial="hidden" whileInView="show" viewport={{ once: true }}>
{items.map((i) => (
<motion.li key={i} variants={item}>{i}</motion.li>
))}
</motion.ul>
Variants propagate — parent state changes flow to children automatically.
Exit Animations with AnimatePresence
Wrap conditionally rendered elements to animate them out before removal:
<AnimatePresence>
{isOpen && (
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
Rules:
- Children must have a unique
key exitprop defines the exit animation- Use
mode="wait"to finish exit before entering next element - Use
mode="popLayout"for layout-aware transitions
Scroll-Linked Animations
Tie animation directly to scroll position:
const { scrollYProgress } = useScroll()
const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0])
const scale = useTransform(scrollYProgress, [0, 1], [1, 0.8])
return <motion.div style={{ opacity, scale }} />
For element-specific scroll tracking:
const ref = useRef(null)
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"], // when element enters/exits viewport
})
Smooth Spring Values
Use useSpring for buttery-smooth transitions of MotionValues:
const scrollY = useMotionValue(0)
const smoothY = useSpring(scrollY, { stiffness: 100, damping: 30 })
Continuous Animation (useAnimationFrame)
For animations that run every frame (gradient shimmer, counting, etc.):
const progress = useMotionValue(0)
useAnimationFrame((time, delta) => {
const newValue = (time / 1000) % 100
progress.set(newValue)
})
const backgroundPosition = useTransform(progress, (p) => `${p}% 50%`)
return <motion.span style={{ backgroundPosition }} />
Layout Animations
Animate layout changes automatically:
// Simple layout animation
<motion.div layout />
// Shared layout animation between components
<motion.div layoutId="hero-image" />
When the same layoutId exists in two different components, Motion animates between them (e.g., thumbnail to full-screen).
Gesture Animations
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.97 }}
whileFocus={{ outline: "2px solid #5227FF" }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
/>
Drag
<motion.div
drag // enable both axes
drag="x" // constrain to x-axis
dragConstraints={{ left: -100, right: 100 }}
dragElastic={0.2}
onDragEnd={(e, info) => {
if (info.offset.x > 100) handleSwipeRight()
}}
/>
Accessibility
useReducedMotion
Always respect the user's motion preferences. This is not optional — it's an accessibility requirement:
import { useReducedMotion } from "framer-motion"
function AnimatedComponent({ children }) {
const prefersReducedMotion = useReducedMotion()
if (prefersReducedMotion) {
return <div>{children}</div>
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
>
{children}
</motion.div>
)
}
Pattern: Check useReducedMotion() and either skip animation entirely or reduce it to opacity-only (no movement).
Performance Rules
-
Never animate
width,height,top,left— these trigger layout recalculation. Usetransformproperties instead (x,y,scale,rotate). -
Use MotionValues for frame-by-frame updates —
useStatecauses re-renders on every frame. MotionValues update the DOM directly. -
will-change: transformis added automatically by motion components — don't add it manually. -
layoutanimations are expensive — use them intentionally, not on every element. -
Avoid animating
box-shadow— usefilter: drop-shadow()or animate opacity of a pseudo-element shadow instead. -
Prefer
opacityandtransform— these are GPU-composited and run on a separate thread. -
Spring transitions are more natural than tween/easing for interactive elements (hover, tap, drag). Reserve tween for scroll-triggered entrances.
Common Mistakes
| Mistake | Fix |
|---|---|
useState for drag position |
useMotionValue |
Manual IntersectionObserver |
whileInView prop |
setTimeout for stagger |
variants with staggerChildren |
Missing key in AnimatePresence |
Add unique key to children |
| Animating in Server Components | Add "use client" or use motion/react-client |
| Ignoring reduced motion | Always check useReducedMotion() |
animate on mount without initial |
Set initial to define start state |
Recipes
Progress Bar on Scroll
const { scrollYProgress } = useScroll()
return (
<motion.div
className="fixed top-0 left-0 right-0 h-1 bg-primary origin-left z-50"
style={{ scaleX: scrollYProgress }}
/>
)
Animated Counter
const count = useMotionValue(0)
const rounded = useTransform(count, (v) => Math.round(v))
useEffect(() => {
const controls = animate(count, target, { duration: 2 })
return controls.stop
}, [target])
return <motion.span>{rounded}</motion.span>
Page Transition (Next.js App Router)
// template.tsx
"use client"
import { motion } from "framer-motion"
export default function Template({ children }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
)
}
Animated Gradient Text (Shimmer Effect)
const progress = useMotionValue(0)
useAnimationFrame((time) => {
const duration = 8000
const fullCycle = duration * 2
const cycleTime = (time % fullCycle)
if (cycleTime < duration) {
progress.set((cycleTime / duration) * 100)
} else {
progress.set(100 - ((cycleTime - duration) / duration) * 100)
}
})
const backgroundPosition = useTransform(progress, (p) => `${p}% 50%`)
return (
<motion.span
className="bg-clip-text text-transparent"
style={{
backgroundImage: "linear-gradient(to right, #5227FF, #a78bfa, #c084fc, #5227FF)",
backgroundSize: "300% 100%",
backgroundPosition,
}}
>
{text}
</motion.span>
)