Framer Motion — Professional Animations in React and Next.js
Advertisement
Introduction
Why This Matters
CSS animations handle simple transitions. Framer Motion handles the complex scenarios: staggered list items, gesture-driven interactions, shared layout animations, scroll-triggered effects, and page transitions. It uses hardware-accelerated transforms and abstracts browser differences.
Installation
npm install framer-motionMark components using Framer Motion as Client Components in Next.js:
'use client'
import { motion } from 'framer-motion'Basic Animation with motion
The motion component adds animation props to any HTML element:
'use client'
import { motion } from 'framer-motion'
export function AnimatedCard({ title, description }: { title: string; description: string }) {
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -24 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="bg-white rounded-xl shadow-md p-6"
>
<h2 className="text-xl font-semibold">{title}</h2>
<p className="text-gray-600 mt-2">{description}</p>
</motion.div>
)
}Variants — Coordinated Animations
Variants let parent components orchestrate child animations:
'use client'
import { motion } from 'framer-motion'
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1, // Each child starts 100ms after the previous
delayChildren: 0.2 // First child starts 200ms after container
}
}
}
const item = {
hidden: { opacity: 0, x: -20 },
show: {
opacity: 1,
x: 0,
transition: { type: 'spring', stiffness: 300, damping: 24 }
}
}
type Post = { id: string; title: string; excerpt: string }
export function PostList({ posts }: { posts: Post[] }) {
return (
<motion.ul variants={container} initial="hidden" animate="show" className="space-y-4">
{posts.map(post => (
<motion.li key={post.id} variants={item} className="bg-white rounded-lg p-4 shadow-sm">
<h3 className="font-semibold">{post.title}</h3>
<p className="text-gray-500 text-sm mt-1">{post.excerpt}</p>
</motion.li>
))}
</motion.ul>
)
}Gesture Animations
'use client'
import { motion } from 'framer-motion'
export function InteractiveCard({ children }: { children: React.ReactNode }) {
return (
<motion.div
whileHover={{ scale: 1.03, boxShadow: '0 20px 40px rgba(0,0,0,0.12)' }}
whileTap={{ scale: 0.97 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
className="bg-white rounded-xl p-6 cursor-pointer"
>
{children}
</motion.div>
)
}
// Drag interaction
export function DraggableTag({ label }: { label: string }) {
return (
<motion.span
drag
dragConstraints={{ top: -20, left: -20, right: 20, bottom: 20 }}
dragElastic={0.2}
whileDrag={{ scale: 1.1, cursor: 'grabbing' }}
className="inline-block bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm cursor-grab"
>
{label}
</motion.span>
)
}Scroll-Triggered Animations
'use client'
import { motion, useInView } from 'framer-motion'
import { useRef } from 'react'
export function ScrollReveal({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
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 } : { opacity: 0, y: 40 }}
transition={{ duration: 0.6, delay, ease: 'easeOut' }}
>
{children}
</motion.div>
)
}
// Parallax scroll effect
import { useScroll, useTransform } from 'framer-motion'
export function ParallaxHero() {
const { scrollY } = useScroll()
const y = useTransform(scrollY, [0, 500], [0, -200])
const opacity = useTransform(scrollY, [0, 300], [1, 0])
return (
<div className="relative h-screen overflow-hidden">
<motion.div style={{ y, opacity }} className="absolute inset-0">
<img src="/hero-bg.jpg" alt="Hero" className="w-full h-full object-cover" />
</motion.div>
<div className="relative z-10 flex items-center justify-center h-full">
<h1 className="text-6xl font-bold text-white">Welcome</h1>
</div>
</div>
)
}Page Transitions in Next.js App Router
// components/page-transition.tsx
'use client'
import { motion, AnimatePresence } from 'framer-motion'
import { usePathname } from 'next/navigation'
const pageVariants = {
initial: { opacity: 0, x: 20 },
in: { opacity: 1, x: 0 },
out: { opacity: 0, x: -20 }
}
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial="initial"
animate="in"
exit="out"
variants={pageVariants}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
{children}
</motion.div>
</AnimatePresence>
)
}Layout Animations
'use client'
import { motion, AnimatePresence } from 'framer-motion'
import { useState } from 'react'
type Item = { id: string; name: string; category: string }
export function FilterableGrid({ items }: { items: Item[] }) {
const [filter, setFilter] = useState<string | null>(null)
const filtered = filter ? items.filter(i => i.category === filter) : items
return (
<div>
<div className="flex gap-2 mb-6">
<button onClick={() => setFilter(null)}>All</button>
{['design', 'engineering', 'marketing'].map(cat => (
<button key={cat} onClick={() => setFilter(cat)}>{cat}</button>
))}
</div>
<motion.div layout className="grid grid-cols-3 gap-4">
<AnimatePresence>
{filtered.map(item => (
<motion.div
key={item.id}
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="bg-white rounded-lg p-4 shadow-sm"
>
{item.name}
</motion.div>
))}
</AnimatePresence>
</motion.div>
</div>
)
}Common Mistakes
- Not wrapping Framer Motion usage in Client Components — always add
'use client' - Animating
widthandheightinstead ofscaleX/scaleY— transforms are GPU-accelerated; layout properties cause reflow - Using
AnimatePresencewithoutmode="wait"for page transitions — causes old and new pages to overlap - Over-animating — animations should enhance, not distract
Best Practices
- Prefer
springtransitions overtweenfor interactive elements — springs feel natural - Use
once: trueinuseInViewfor scroll animations that should not replay - Wrap list items in
AnimatePresenceonly when items can be removed — not for static lists
Key Takeaways
motion.div(andmotion.any-html-element) addsinitial,animate,exit, andtransitionpropsvariantsenable parent-to-child animation orchestration withstaggerChildrenfor sequential revealswhileHover,whileTap, andwhileDragadd gesture-driven animations with spring physicsuseInViewtriggers animations when elements enter the viewport — useonce: trueto prevent replayuseScrollanduseTransformconnect scroll position to motion values for parallax and reveal effectsAnimatePresenceis required for exit animations — wrap the component and ensure each child has a uniquekeylayoutprop enables Framer Motion's automatic layout animation when elements change position or size- Always animate
transformproperties (x,y,scale,rotate) not layout properties (width,top) for performance
Advertisement
Related reading
Build an AI Chatbot with Next.js 15 and OpenAI — Full Stack 20266 min readNext.js + tRPC Guide — End-to-End Type Safety Best Practices 20265 min readNext.js 15 New Features — Every Breaking Change and Upgrade Explained7 min readNext.js App Router — The Complete Guide for 20267 min readNext.js Server Actions — Replace API Routes with Type-Safe Server Functions 20267 min readReact Server Components in Next.js — Complete Guide 20268 min read