Framer Motion — Professional Animations in React and Next.js

Sanjeev SharmaSanjeev Sharma
5 min read

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-motion

Mark 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 width and height instead of scaleX/scaleY — transforms are GPU-accelerated; layout properties cause reflow
  • Using AnimatePresence without mode="wait" for page transitions — causes old and new pages to overlap
  • Over-animating — animations should enhance, not distract

Best Practices

  • Prefer spring transitions over tween for interactive elements — springs feel natural
  • Use once: true in useInView for scroll animations that should not replay
  • Wrap list items in AnimatePresence only when items can be removed — not for static lists

Key Takeaways

  • motion.div (and motion.any-html-element) adds initial, animate, exit, and transition props
  • variants enable parent-to-child animation orchestration with staggerChildren for sequential reveals
  • whileHover, whileTap, and whileDrag add gesture-driven animations with spring physics
  • useInView triggers animations when elements enter the viewport — use once: true to prevent replay
  • useScroll and useTransform connect scroll position to motion values for parallax and reveal effects
  • AnimatePresence is required for exit animations — wrap the component and ensure each child has a unique key
  • layout prop enables Framer Motion's automatic layout animation when elements change position or size
  • Always animate transform properties (x, y, scale, rotate) not layout properties (width, top) for performance

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading