Framer Motion for React: A Practical Tutorial with Patterns and Pitfalls
Hands-on Framer Motion tutorial for React: setup, key APIs, gestures, layout, AnimatePresence, scroll, SVG, performance, TypeScript, testing.
Image used for representation purposes only.
Overview
Framer Motion is a production-ready animation library for React that pairs a clean API with powerful features: layout transitions, gestures, scroll-based effects, and exit animations. In this hands-on tutorial, you’ll learn the core concepts, how to structure animations for scalability, and the patterns that keep interactions smooth and accessible.
Prerequisites and setup
- Familiarity with React 18+ (functional components and hooks)
- Node.js 16+
- A bundler or framework (Vite, CRA, Next.js)
Install:
npm i framer-motion
# or
yarn add framer-motion
Import the building blocks when you need them:
import { motion, AnimatePresence, useAnimation, useScroll, useSpring, useTransform, MotionConfig } from "framer-motion";
Your first animation: fade and slide
Framer Motion revolves around the motion component family (e.g., motion.div, motion.button). Use initial, animate, and transition to define states.
import { motion } from "framer-motion";
export function HelloMotion() {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
style={{ fontSize: 24, fontWeight: 600 }}
>
Hello, Motion!
</motion.div>
);
}
Tips:
- Prefer animating
opacity,transform(translate/scale/rotate) for best performance. - Keep durations short (150–600ms) to feel responsive.
Reusable variants and sequencing
Variants define named states you can reuse across elements. They shine for coordinating parent/child animations and staggering.
import { motion, Variants } from "framer-motion";
const list: Variants = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: { staggerChildren: 0.12, delayChildren: 0.1 }
}
};
const item: Variants = {
hidden: { opacity: 0, y: 10 },
show: { opacity: 1, y: 0 }
};
export function StaggeredList({ items }: { items: string[] }) {
return (
<motion.ul variants={list} initial="hidden" animate="show" style={{ listStyle: "none", padding: 0 }}>
{items.map(text => (
<motion.li key={text} variants={item} style={{ margin: "8px 0" }}>
{text}
</motion.li>
))}
</motion.ul>
);
}
Why variants:
- Centralize animation logic
- Enable orchestration with
staggerChildrenandwhen: "beforeChildren"|"afterChildren"
Interactions with gestures
Micro-interactions make UI feel alive. Use whileHover, whileTap, and whileFocus for intent-aware feedback.
export function CTAButton() {
return (
<motion.button
whileHover={{ scale: 1.05, y: -1 }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className="btn-primary"
>
Get Started
</motion.button>
);
}
Drag and physics
Enable direct manipulation with one prop.
export function DraggableCard() {
return (
<motion.div
drag
dragConstraints={{ left: -80, right: 80, top: -40, bottom: 40 }}
dragElastic={0.2}
whileDrag={{ scale: 1.02 }}
style={{ width: 220, height: 120, borderRadius: 12, background: "#111", color: "#fff", display: "grid", placeItems: "center" }}
>
Drag me
</motion.div>
);
}
Notes:
- Use
dragMomentum={false}to stop glide after release. - Constrain via a parent ref with
dragConstraints={ref}for dynamic bounds.
Layout transitions and shared elements
Layout animations automatically interpolate between size/position when React’s layout changes.
export function ExpandableCard({ expanded }: { expanded: boolean }) {
return (
<motion.section
layout
transition={{ layout: { duration: 0.35, ease: [0.2, 0, 0, 1] } }}
style={{ borderRadius: 16, padding: 16, background: "#fafafa" }}
>
<motion.h3 layout>Title</motion.h3>
{expanded && (
<motion.p layout style={{ color: "#555" }}>
Auto-layout smoothly animates height changes without manual keyframes.
</motion.p>
)}
</motion.section>
);
}
For cross-screen or modal transitions, use layoutId to link two elements across different trees.
export function Gallery({ selected, setSelected, photos }: { selected?: string; setSelected: (id?: string) => void; photos: { id: string; src: string }[] }) {
return (
<div className="grid">
{photos.map(p => (
<motion.img key={p.id} src={p.src} layoutId={p.id} onClick={() => setSelected(p.id)} />
))}
<AnimatePresence>
{selected && (
<motion.div className="lightbox" onClick={() => setSelected(undefined)}>
<motion.img src={photos.find(x => x.id === selected)!.src} layoutId={selected} />
</motion.div>
)}
</AnimatePresence>
</div>
);
}
Mount/unmount flow with AnimatePresence
Animate components as they enter or leave the tree. Wrap conditional children with AnimatePresence and define initial, animate, and exit.
export function Drawer({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
return (
<AnimatePresence>
{isOpen && (
<motion.aside
key="drawer"
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "tween", ease: "easeInOut", duration: 0.3 }}
className="drawer"
>
<button onClick={onClose} aria-label="Close">×</button>
</motion.aside>
)}
</AnimatePresence>
);
}
Common gotchas:
- Always provide stable
keyvalues for siblings. - For route transitions, put
AnimatePresencenear your router outlet and key on the path.
Scroll-based effects
Create progress indicators or parallax using useScroll, useSpring, and useTransform.
export function ScrollProgressBar() {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, { stiffness: 120, damping: 20, mass: 0.2 });
return (
<motion.div style={{ position: "fixed", top: 0, left: 0, right: 0, height: 3, background: "#eee", transformOrigin: "0 0" }}>
<motion.div style={{ height: "100%", background: "#7c3aed", scaleX }} />
</motion.div>
);
}
Parallax example:
export function HeroParallax() {
const { scrollYProgress } = useScroll();
const y = useTransform(scrollYProgress, [0, 1], ["0vh", "-20vh"]);
return (
<section style={{ height: "150vh", position: "relative", overflow: "hidden" }}>
<motion.img src="/hero.jpg" alt="Hero" style={{ position: "absolute", inset: 0, width: "100%", height: "auto", y }} />
</section>
);
}
SVG and icon animations
SVG paths can be animated with pathLength for elegant “draw-in” effects.
export function Checkmark() {
return (
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2" strokeLinecap="round">
<motion.path
d="M20 6L9 17l-5-5"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.8, ease: "easeInOut" }}
/>
</svg>
);
}
Imperative control and sequences
The useAnimation hook returns a controller you can start/stop programmatically—useful for wizards, toasts, or analytics-triggered motion.
export function ControlledIntro() {
const controls = useAnimation();
React.useEffect(() => {
const run = async () => {
await controls.start({ opacity: 1, y: 0, transition: { duration: 0.4 } });
await controls.start({ scale: [1, 1.05, 1], transition: { duration: 0.3 } });
};
run();
}, [controls]);
return (
<motion.h2 initial={{ opacity: 0, y: 10 }} animate={controls}>Welcome</motion.h2>
);
}
Performance, bundle size, and SSR
- Prefer
transform+opacity. Avoid animating layout-affecting properties likewidth/topunless usinglayout. - Batch updates with variants to reduce re-renders.
- Use
LazyMotionto load features only when needed:
import { LazyMotion, domAnimation, motion } from "framer-motion";
export function LightweightMotion() {
return (
<LazyMotion features={domAnimation} strict>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
</LazyMotion>
);
}
- Next.js/SSR: If you see hydration warnings from differing initial/animate states, try
initial={false}for route-level transitions or defer complex client-only motion with a dynamic import.
Accessibility and reduced motion
Respect user preferences with MotionConfig and useReducedMotion.
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<MotionConfig reducedMotion="user">
{children}
</MotionConfig>
);
}
import { useReducedMotion } from "framer-motion";
export function SubtleCard() {
const prefersReduced = useReducedMotion();
const hover = prefersReduced ? {} : { y: -2, scale: 1.02 };
return (
<motion.div whileHover={hover} transition={{ duration: 0.15 }} className="card">
Accessible hover
</motion.div>
);
}
TypeScript tips
- Type variants with
Variantsto catch typos and invalid values. - Narrow prop types when passing animation props around.
import type { Variants, Transition } from "framer-motion";
const cardVariants: Variants = {
hidden: { opacity: 0, y: 12 },
show: { opacity: 1, y: 0, transition: { duration: 0.3 } as Transition }
};
Testing strategies
- Wrap tests with
MotionConfig reducedMotion="always"to eliminate flake from long animations. - Assert for final states (e.g., element visible/hidden) rather than intermediate transforms.
import { render, screen } from "@testing-library/react";
import { MotionConfig } from "framer-motion";
it("renders immediate state for tests", () => {
render(
<MotionConfig reducedMotion="always">
<HelloMotion />
</MotionConfig>
);
expect(screen.getByText(/hello/i)).toBeInTheDocument();
});
Common pitfalls and fixes
- Flicker on mount with SSR: set
initial={false}on route-level containers. - Exit animations not firing: ensure the exiting node is wrapped in
AnimatePresenceand has a stablekey. - Janky layout transitions: avoid animating
width/heightdirectly; uselayoutor transform scale withtransform-origin. - Shared element mismatch:
layoutIdmust be unique across the tree and present in both source and destination.
Complete example: card grid with presence and variants
import * as React from "react";
import { motion, AnimatePresence, Variants } from "framer-motion";
const grid: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.08, delayChildren: 0.05 } }
};
const tile: Variants = {
hidden: { opacity: 0, y: 10 },
show: { opacity: 1, y: 0 },
exit: { opacity: 0, scale: 0.95 }
};
export function FilterableGrid({ items }: { items: { id: string; title: string }[] }) {
const [query, setQuery] = React.useState("");
const filtered = items.filter(i => i.title.toLowerCase().includes(query.toLowerCase()));
return (
<div>
<input placeholder="Search" value={query} onChange={e => setQuery(e.target.value)} />
<motion.ul variants={grid} initial="hidden" animate="show" style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", listStyle: "none", padding: 0 }}>
<AnimatePresence mode="popLayout">
{filtered.map((i) => (
<motion.li key={i.id} layout variants={tile} exit="exit" className="tile">
{i.title}
</motion.li>
))}
</AnimatePresence>
</motion.ul>
</div>
);
}
What’s happening:
layoutenables smooth reflow when the list changes size.AnimatePresenceanimates removed items withexit.- Parent
variantsstagger tile entrance.
Next steps
- Build a shared-element route transition with
layoutId. - Add
dragto cards for a playful inbox triage. - Use
useScroll+useTransformfor parallax in hero and content sections.
With these patterns—variants, gestures, layout, presence, and scroll—you can design fluid, accessible motion systems that scale across your React app without sacrificing performance.
Related Posts
React useMemo and useCallback: A Practical Optimization Guide
Practical guide to React’s useMemo and useCallback: when to use them, pitfalls to avoid, patterns, and profiling tips for faster apps.
React Hydration Mismatch: A Practical Debugging Guide
Learn how to diagnose and fix React hydration mismatches with step-by-step checks, common causes, and production-safe patterns for SSR and Next.js.
React Compiler Automatic Optimization: A Practical Guide
A practical guide to React’s automatic compiler optimizations: how it works, code patterns that help or hurt, migration steps, and how to measure impact.