Framer Motion Patterns
Production-grade web UI animation for React/Next.js. Apply when adding motion to components, not just fixing existing jank.
When to Apply
- Entrance/exit animations on components
- Gesture interactions (drag, hover, tap, swipe)
- Page/route transitions
- Scroll-triggered reveals and parallax
- Layout animations (list reorder, expand/collapse)
- Loading sequences, skeleton animations, micro-interactions
Not For
- Video/programmatic animation →
remotion-animation - Fixing animation perf bugs →
fixing-motion-performance - p5.js generative art →
algorithmic-art
1. Variants (the right choreography pattern)
Variants keep animation state in data. Always prefer them over inline animate props for anything beyond trivial cases.
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.08,
delayChildren: 0.1,
},
},
}
const itemVariants = {
hidden: { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: { type: "spring", stiffness: 300, damping: 24 },
},
}
// Usage — variants propagate to children automatically
<motion.ul variants={containerVariants} initial="hidden" animate="visible">
{items.map((item) => (
<motion.li key={item.id} variants={itemVariants}>
{item.label}
</motion.li>
))}
</motion.ul>
2. Spring Physics (prefer over duration/easing)
Springs feel physical. Avoid arbitrary durations — springs self-document through stiffness and damping.
| Feel | stiffness | damping |
|---|---|---|
| Bouncy | 400 | 10 |
| Snappy | 300 | 24 |
| Smooth | 100 | 20 |
| Slow/heavy | 50 | 15 |
// No bounce (UI default)
transition={{ type: "spring", stiffness: 300, damping: 30 }}
// Bouncy (playful)
transition={{ type: "spring", stiffness: 400, damping: 10 }}
// Never use duration for springs — that defeats the point
3. Gesture Interactions
// Hover + tap feedback
<motion.button
whileHover={{ scale: 1.02, backgroundColor: "#f0f0f0" }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 20 }}
>
Click me
</motion.button>
// Draggable with elastic constraints
<motion.div
drag
dragConstraints={{ left: -100, right: 100, top: -50, bottom: 50 }}
dragElastic={0.2}
whileDrag={{ scale: 1.05, boxShadow: "0 10px 30px rgba(0,0,0,0.2)" }}
/>
// Swipe to dismiss
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 0 }}
info) => {
if (Math.abs(info.offset.x) > 100) dismiss()
}}
/>
4. Scroll Animations
import { useInView } from "framer-motion"
import { useRef } from "react"
// Reveal on scroll — once only, with bottom margin
function RevealOnScroll({ children }: { children: React.ReactNode }) {
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={{ type: "spring", stiffness: 100, damping: 20 }}
>
{children}
</motion.div>
)
}
import { useScroll, useTransform } from "framer-motion"
// Parallax hero image
function ParallaxHero() {
const { scrollY } = useScroll()
const y = useTransform(scrollY, [0, 500], [0, 150])
const opacity = useTransform(scrollY, [0, 300], [1, 0])
return <motion.div style={{ y, opacity }} className="hero-background" />
}
// Scroll progress bar
function ProgressBar() {
const { scrollYProgress } = useScroll()
return (
<motion.div
className="fixed top-0 left-0 right-0 h-1 bg-blue-500 origin-left"
style={{ scaleX: scrollYProgress }}
/>
)
}
5. AnimatePresence + Exit Animations
Exit animations only work inside AnimatePresence.
import { AnimatePresence, motion } from "framer-motion"
// Conditional render with exit
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
>
{content}
</motion.div>
)}
</AnimatePresence>
// List with removal animation
<AnimatePresence>
{items.map((item) => (
<motion.li
key={item.id}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
{item.content}
</motion.li>
))}
</AnimatePresence>
6. Layout Animations
import { LayoutGroup, motion } from "framer-motion"
// Animate list reorder (add layout prop)
<LayoutGroup>
{items.map((item) => (
<motion.div key={item.id} layout>
{item.content}
</motion.div>
))}
</LayoutGroup>
// Expanding card (layout="size" + AnimatePresence for content)
<motion.div layout="size" className="card">
<motion.h2 layout="position">{title}</motion.h2>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{expandedContent}
</motion.div>
)}
</AnimatePresence>
</motion.div>
7. Page Transitions (Next.js App Router)
// app/template.tsx — wraps every route change
"use client"
import { motion } from "framer-motion"
export default function Template({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
{children}
</motion.div>
)
}
8. Shared Layout (cross-route element morphing)
// List page — element tagged with layoutId
<motion.div layoutId={`card-${id}`} className="card-thumbnail" />
// Detail page — same layoutId, Framer Motion animates between them
<motion.div layoutId={`card-${id}`} className="card-full" />
9. useAnimation (imperative control)
import { useAnimation } from "framer-motion"
const controls = useAnimation()
// Chain animations imperatively
async function runSequence() {
await controls.start({ x: 100, transition: { duration: 0.3 } })
await controls.start({ rotate: 360, transition: { duration: 0.5 } })
controls.stop()
}
<motion.div animate={controls} />
Common Micro-interaction Presets
// Tooltip
const tooltipAnim = {
initial: { opacity: 0, y: 4, scale: 0.95 },
animate: { opacity: 1, y: 0, scale: 1 },
exit: { opacity: 0, y: 4, scale: 0.95 },
transition: { duration: 0.12 }
}
// Slide-in notification
const notificationAnim = {
initial: { opacity: 0, x: 40 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 40, transition: { duration: 0.15 } },
transition: { type: "spring", stiffness: 400, damping: 30 }
}
// Modal backdrop + content
const backdropAnim = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
}
const modalAnim = {
initial: { opacity: 0, scale: 0.95, y: 16 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.95, y: 8 },
transition: { type: "spring", stiffness: 400, damping: 30 }
}
// Accordion expand
const accordionAnim = {
initial: { height: 0, opacity: 0 },
animate: { height: "auto", opacity: 1 },
exit: { height: 0, opacity: 0 },
transition: { duration: 0.2, ease: "easeOut" }
}
Performance Rules
- Animate only compositor properties —
transform,opacity. Neverwidth,height,top,left,padding. layoutprop is expensive — only use when list reorder/size animation is genuinely needed.- Respect
prefers-reduced-motion:
import { useReducedMotion } from "framer-motion"
function SafeAnimation({ children }) {
const shouldReduce = useReducedMotion()
return (
<motion.div
initial={{ opacity: 0, y: shouldReduce ? 0 : 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduce ? 0.01 : 0.4 }}
>
{children}
</motion.div>
)
}
AnimatePresenceis required for exit animations — without it,exitprops are silently ignored.mode="wait"on AnimatePresence when only one child should be visible at a time (e.g., route transitions):
<AnimatePresence mode="wait">
<motion.div key={route} exit={{ opacity: 0 }} />
</AnimatePresence>
Installation
pnpm add framer-motion
# or
npm install framer-motion
Cross-References
fixing-motion-performance— audit/fix jank in existing animationsremotion-animation— programmatic video animation (Remotion library)frontend-design— visual aesthetics, typography, compositionresponsive-design— responsive layout; pair withuseReducedMotionbaseline-ui— Tailwind CSS defaults