Before Framer Motion, I spent too much time wrestling with @keyframes and CSS transitions. Interactive animations — hover effects, modals, page transitions — got especially messy when mixed with React state management.
Framer Motion changed how I think about animation in React. It's declarative, works naturally with React state, and handles the tricky parts (like animating components as they unmount) automatically. This article covers practical patterns from basic fade-ins to scroll-triggered effects and layout animations.
Core Concepts
Framer Motion centers around the motion component. Prefix any HTML element with motion. and it gains animation capabilities:
import { motion } from "framer-motion";
function FadeInBox() {
return (
<motion.div
initial={{ opacity: 0 }} // Starting state
animate={{ opacity: 1 }} // Target state
transition={{ duration: 0.5 }} // Animation settings
>
This fades in on mount
</motion.div>
);
}
Three props to learn:
initial— state before the animation startsanimate— target state to animate totransition— duration, easing, delay, etc.
Common Animation Properties
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.4,
ease: "easeOut",
delay: 0.1,
}}
>
Content
</motion.div>
Almost any CSS property works, but prefer opacity and transform properties (x, y, scale, rotate). They're GPU-accelerated and don't trigger layout reflow — keeping your animations smooth.
Fade and Slide Patterns
Starting with the most common patterns.
Page Load Fade-in
"use client";
import { motion } from "framer-motion";
function HeroSection() {
return (
<section>
<motion.h1
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
Page Title
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.15 }}
>
Subtext here
</motion.p>
<motion.button
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut", delay: 0.3 }}
>
Get Started
</motion.button>
</section>
);
}
Incrementing delay creates a stagger effect — elements appear sequentially.
Hover Animations
"use client";
import { motion } from "framer-motion";
function AnimatedCard({ title, description }: { title: string; description: string }) {
return (
<motion.div
className="rounded-lg border p-6 cursor-pointer"
whileHover={{
scale: 1.02,
boxShadow: "0 10px 40px rgba(0,0,0,0.15)",
}}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
>
<h3>{title}</h3>
<p>{description}</p>
</motion.div>
);
}
whileHover and whileTap are the declarative equivalents of CSS :hover and :active.
Variants for Complex Animations
When multiple elements animate together, use variants — named animation states that can be shared between parent and children.
"use client";
import { motion } from "framer-motion";
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1, // Stagger children by 0.1s
delayChildren: 0.2, // Wait 0.2s before starting children
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
function AnimatedList({ items }: { items: string[] }) {
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
>
{items.map((item, i) => (
<motion.li key={i} variants={itemVariants}>
{item}
</motion.li>
))}
</motion.ul>
);
}
staggerChildren does the heavy lifting here. When the container transitions to "visible", each child starts its own animation with a delay offset. No manual delay management needed — just pass the same variants to each motion.li.
Scroll-Triggered Animations (whileInView)
whileInView triggers an animation when the element enters the viewport:
"use client";
import { motion } from "framer-motion";
function FeatureCard({ icon, title, description }: FeatureCardProps) {
return (
<motion.div
className="rounded-xl p-8 border"
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }} // Trigger 50px before fully in view
transition={{ duration: 0.5, ease: "easeOut" }}
>
<div className="text-4xl mb-4">{icon}</div>
<h3 className="text-xl font-bold mb-2">{title}</h3>
<p>{description}</p>
</motion.div>
);
}
viewport options:
once: true— animate only the first time it enters view (recommended for content reveals)margin— offset from the viewport edge; negative values trigger before fully visible
AnimatePresence: Animate Mount and Unmount
By default, React removes components from the DOM instantly. AnimatePresence holds them in the DOM until their exit animation completes.
"use client";
import { motion, AnimatePresence } from "framer-motion";
function Modal({ isOpen, onClose, children }: ModalProps) {
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
key="overlay"
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
key="modal"
className="fixed inset-x-4 top-1/2 -translate-y-1/2 max-w-lg mx-auto
bg-white rounded-2xl p-8 shadow-2xl z-10"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ type: "spring", stiffness: 350, damping: 30 }}
>
{children}
<button onClick={onClose}>Close</button>
</motion.div>
</>
)}
</AnimatePresence>
);
}
The exit prop defines the animation that plays before the component is removed. Without AnimatePresence, exit is ignored.
Toast Notifications
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
type Toast = { id: number; message: string };
function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = (message: string) => {
const id = Date.now();
setToasts(prev => [...prev, { id, message }]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 3000);
};
return (
<>
<button onClick={() => addToast("Saved!")}>Save</button>
<div className="fixed bottom-4 right-4 flex flex-col gap-2">
<AnimatePresence>
{toasts.map(toast => (
<motion.div
key={toast.id}
className="bg-gray-900 text-white px-4 py-3 rounded-lg shadow-lg"
initial={{ opacity: 0, x: 50, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 50, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
{toast.message}
</motion.div>
))}
</AnimatePresence>
</div>
</>
);
}
useAnimate for Imperative Sequences
For complex sequential animations or animations triggered at specific moments, useAnimate gives you imperative control:
"use client";
import { useAnimate } from "framer-motion";
function SubmitButton() {
const [scope, animate] = useAnimate();
const handleSubmit = async () => {
// 1. Press feedback
await animate(scope.current, { scale: 0.95 }, { duration: 0.1 });
await animate(scope.current, { scale: 1 }, { duration: 0.1 });
// 2. Loading state
animate(scope.current, { opacity: 0.7 });
try {
await submitForm();
// 3. Success feedback
await animate(scope.current, { backgroundColor: "#22c55e" }, { duration: 0.3 });
await animate(scope.current, { backgroundColor: "#3b82f6" }, { duration: 0.5 });
} catch {
// 4. Error shake
await animate(scope.current, { x: [-8, 8, -8, 8, 0] }, { duration: 0.4 });
}
};
return (
<button ref={scope} onClick={handleSubmit} className="px-6 py-3 bg-blue-500 rounded-lg">
Submit
</button>
);
}
animate returns a Promise, so you can await each step and chain them sequentially.
Layout Animations with layoutId
layoutId smoothly animates an element from one position to another — even across different component renders:
"use client";
import { motion, LayoutGroup } from "framer-motion";
import { useState } from "react";
function TabNav() {
const [activeTab, setActiveTab] = useState("home");
const tabs = ["home", "about", "contact"];
return (
<LayoutGroup>
<nav className="flex gap-4 relative">
{tabs.map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className="relative px-4 py-2"
>
{tab}
{activeTab === tab && (
<motion.div
layoutId="active-indicator"
className="absolute inset-0 bg-blue-100 rounded-md -z-10"
/>
)}
</button>
))}
</nav>
</LayoutGroup>
);
}
When active-indicator moves to a different tab, Framer Motion automatically animates it from the old position to the new one. No manual coordinate tracking needed.
Performance Best Practices
Stick to Transform and Opacity
// ❌ Causes layout reflow — expensive
<motion.div animate={{ width: "100%", height: 200, top: 50 }} />
// ✅ GPU-accelerated — no layout reflow
<motion.div animate={{ scaleX: 1, scaleY: 1, y: 50, opacity: 1 }} />
When you animate properties like width, height, or top, the browser recalculates layout for every frame. Sticking to transform and opacity keeps animations in the GPU and off the main thread.
Reduce Motion for Accessibility
"use client";
import { motion, useReducedMotion } from "framer-motion";
function AnimatedCard({ children }: { children: React.ReactNode }) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.4 }}
>
{children}
</motion.div>
);
}
useReducedMotion respects the user's OS-level "Reduce Motion" preference. Always consider this for production apps.
Wrapping Up
Core Framer Motion patterns in one table:
| Pattern | Use Case | Key Props |
|---|---|---|
| Basic animation | Fade-in, slide | initial, animate, transition |
| Hover / tap | Interactive feedback | whileHover, whileTap |
| Variants | List stagger, coordinated animations | variants, staggerChildren |
| Scroll-triggered | Content reveal | whileInView, viewport |
| Mount / unmount | Modals, toasts | AnimatePresence, exit |
| Sequential control | Complex choreography | useAnimate |
| Layout changes | Tabs, list reordering | layoutId, LayoutGroup |
The most important rule: don't over-animate. opacity + y covers 80% of cases beautifully. Start simple, reach for complex patterns only when needed, and always keep transform/opacity as your primary animation properties for performance.