Mastering Framer Motion: Production-Grade Animations for React Applications
A deep dive into building polished, performant animations with Framer Motion in React and Next.js — covering layout animations, gesture-driven interactions, scroll-triggered effects, and shared layout transitions.
Mastering Framer Motion: Production-Grade Animations for React Applications
Animations are no longer just a nice-to-have — they're a core part of modern user experience. Smooth transitions, micro-interactions, and scroll-driven effects communicate responsiveness and guide users through your interface. Having built animation-heavy production apps like Reflys, I've developed a toolkit of Framer Motion patterns that deliver polish without sacrificing performance.
Why Framer Motion?
Framer Motion is the de facto animation library for React because it:
- Provides a declarative API that fits naturally into React's component model
- Handles layout animations automatically
- Supports gesture recognition (drag, hover, tap, pan)
- Enables server-side rendering compatibility with Next.js
- Maintains 60fps through hardware-accelerated transforms
Foundational Patterns
Entrance Animations
The most common pattern — animating elements as they mount:
import { motion } from 'framer-motion';
const fadeInUp = {
initial: { opacity: 0, y: 30 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] },
};
export const HeroSection = () => (
<motion.section
initial={fadeInUp.initial}
animate={fadeInUp.animate}
transition={fadeInUp.transition}
className="hero"
>
<h1>Build Something Beautiful</h1>
<p>Crafted with precision and care.</p>
</motion.section>
);
Staggered Children
Animate list items with a cascading delay:
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.08,
delayChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20, scale: 0.95 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.4, ease: 'easeOut' },
},
};
export const FeatureGrid: React.FC<{ features: Feature[] }> = ({ features }) => (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="grid grid-cols-1 md:grid-cols-3 gap-6"
>
{features.map((feature) => (
<motion.div key={feature.id} variants={itemVariants} className="feature-card">
<feature.icon className="w-8 h-8" />
<h3>{feature.title}</h3>
<p>{feature.description}</p>
</motion.div>
))}
</motion.div>
);
Scroll-Triggered Animations
Using whileInView
The simplest approach for scroll-triggered effects:
export const StatsSection = () => {
const stats = [
{ label: 'Projects Delivered', value: '50+' },
{ label: 'Happy Clients', value: '30+' },
{ label: 'Years Experience', value: '4+' },
];
return (
<section className="py-20">
<div className="grid grid-cols-3 gap-8">
{stats.map((stat, index) => (
<motion.div
key={stat.label}
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true, margin: '-100px' }}
transition={{
duration: 0.5,
delay: index * 0.1,
type: 'spring',
stiffness: 100,
}}
className="text-center"
>
<h3 className="text-4xl font-bold">{stat.value}</h3>
<p className="text-gray-500">{stat.label}</p>
</motion.div>
))}
</div>
</section>
);
};
Scroll-Linked Progress Animations
For effects that track scroll position precisely:
import { motion, useScroll, useTransform } from 'framer-motion';
import { useRef } from 'react';
export const ParallaxSection = () => {
const containerRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ['start end', 'end start'],
});
const y = useTransform(scrollYProgress, [0, 1], [100, -100]);
const opacity = useTransform(scrollYProgress, [0, 0.3, 0.7, 1], [0, 1, 1, 0]);
const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.8, 1, 0.8]);
return (
<div ref={containerRef} className="relative h-screen overflow-hidden">
<motion.div style={{ y, opacity, scale }} className="absolute inset-0">
<img src="/hero-bg.jpg" alt="Parallax background" className="object-cover" />
</motion.div>
<div className="relative z-10 flex items-center justify-center h-full">
<h2 className="text-5xl font-bold text-white">Scroll Magic</h2>
</div>
</div>
);
};
Gesture-Driven Interactions
Draggable Cards
Create Tinder-style swipeable cards:
import { motion, useMotionValue, useTransform, PanInfo } from 'framer-motion';
interface SwipeCardProps {
card: { id: string; title: string; image: string };
onSwipe: (direction: 'left' | 'right') => void;
}
export const SwipeCard: React.FC<SwipeCardProps> = ({ card, onSwipe }) => {
const x = useMotionValue(0);
const rotate = useTransform(x, [-200, 200], [-25, 25]);
const opacity = useTransform(x, [-200, -100, 0, 100, 200], [0.5, 1, 1, 1, 0.5]);
const handleDragEnd = (_: MouseEvent, info: PanInfo) => {
if (info.offset.x > 100) {
onSwipe('right');
} else if (info.offset.x < -100) {
onSwipe('left');
}
};
return (
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.8}
onDragEnd={handleDragEnd}
style={{ x, rotate, opacity }}
whileTap={{ cursor: 'grabbing' }}
className="absolute w-80 h-96 rounded-2xl bg-white shadow-xl cursor-grab"
>
<img src={card.image} alt={card.title} className="rounded-t-2xl h-3/4 object-cover" />
<h3 className="p-4 text-xl font-semibold">{card.title}</h3>
</motion.div>
);
};
Interactive Hover Effects
Subtle hover animations that elevate UI quality:
export const ProjectCard: React.FC<{ project: Project }> = ({ project }) => (
<motion.article
whileHover={{ y: -8, boxShadow: '0 20px 40px rgba(0,0,0,0.12)' }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="rounded-xl overflow-hidden bg-white border border-gray-100"
>
<motion.div
whileHover={{ scale: 1.05 }}
transition={{ duration: 0.4 }}
className="overflow-hidden"
>
<img src={project.image} alt={project.title} className="w-full h-48 object-cover" />
</motion.div>
<div className="p-6">
<h3 className="text-lg font-bold">{project.title}</h3>
<p className="text-gray-600 mt-2">{project.description}</p>
<div className="flex gap-2 mt-4">
{project.tags.map((tag) => (
<span key={tag} className="px-3 py-1 bg-gray-100 rounded-full text-sm">
{tag}
</span>
))}
</div>
</div>
</motion.article>
);
Layout Animations
Shared Layout Transitions
One of Framer Motion's most powerful features — smooth transitions between different layouts:
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
export const ExpandableCard: React.FC<{ items: Item[] }> = ({ items }) => {
const [selectedId, setSelectedId] = useState<string | null>(null);
const selectedItem = items.find((item) => item.id === selectedId);
return (
<>
<div className="grid grid-cols-2 gap-4">
{items.map((item) => (
<motion.div
key={item.id}
layoutId={`card-${item.id}`}
onClick={() => setSelectedId(item.id)}
className="p-6 rounded-xl bg-white cursor-pointer"
>
<motion.h3 layoutId={`title-${item.id}`}>{item.title}</motion.h3>
<motion.p layoutId={`desc-${item.id}`}>{item.subtitle}</motion.p>
</motion.div>
))}
</div>
<AnimatePresence>
{selectedItem && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedId(null)}
>
<motion.div
layoutId={`card-${selectedItem.id}`}
className="w-full max-w-lg p-8 rounded-2xl bg-white"
onClick={(e) => e.stopPropagation()}
>
<motion.h3 layoutId={`title-${selectedItem.id}`} className="text-2xl font-bold">
{selectedItem.title}
</motion.h3>
<motion.p layoutId={`desc-${selectedItem.id}`} className="mt-2 text-gray-600">
{selectedItem.subtitle}
</motion.p>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="mt-4"
>
<p>{selectedItem.fullDescription}</p>
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
};
AnimatePresence for Exit Animations
Handle mount/unmount transitions gracefully:
export const NotificationStack: React.FC<{ notifications: Notification[] }> = ({
notifications,
}) => (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
<AnimatePresence mode="popLayout">
{notifications.map((notification) => (
<motion.div
key={notification.id}
layout
initial={{ opacity: 0, x: 100, scale: 0.8 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.8 }}
transition={{ type: 'spring', damping: 20, stiffness: 300 }}
className="p-4 rounded-lg bg-white shadow-lg border min-w-[300px]"
>
<p className="font-medium">{notification.title}</p>
<p className="text-sm text-gray-500">{notification.message}</p>
</motion.div>
))}
</AnimatePresence>
</div>
);
Performance Best Practices
1. Animate Transform Properties Only
Stick to x, y, scale, rotate, and opacity — these are GPU-accelerated and won't trigger layout recalculations.
2. Use will-change Sparingly
Framer Motion handles this internally, but for custom CSS animations:
.animate-target {
will-change: transform, opacity;
}
3. Reduce Motion for Accessibility
Always respect the user's motion preferences:
import { useReducedMotion } from 'framer-motion';
export const AccessibleAnimation: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const prefersReducedMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: prefersReducedMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.5 }}
>
{children}
</motion.div>
);
};
4. Lazy-Load Animation-Heavy Components
In Next.js, dynamically import Framer Motion components to reduce initial bundle size:
import dynamic from 'next/dynamic';
const AnimatedHero = dynamic(() => import('./AnimatedHero'), {
ssr: false,
loading: () => <HeroSkeleton />,
});
Conclusion
Framer Motion transforms React applications from static interfaces into living, breathing experiences. The key is restraint — every animation should serve a purpose, whether it's guiding attention, providing feedback, or creating spatial context.
Key takeaways:
- Use variants and staggerChildren for orchestrated animations
- Leverage scroll-linked animations for immersive storytelling
- Always respect reduced motion preferences
- Stick to transform and opacity for 60fps performance
- Use layout animations for seamless state transitions
- Lazy-load heavy animation components in Next.js
The difference between a good app and a great app often comes down to those subtle micro-interactions that make the interface feel alive.