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.

ASOasis
7 min read
Framer Motion for React: A Practical Tutorial with Patterns and Pitfalls

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 staggerChildren and when: "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 key values for siblings.
  • For route transitions, put AnimatePresence near 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 like width/top unless using layout.
  • Batch updates with variants to reduce re-renders.
  • Use LazyMotion to 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 Variants to 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 AnimatePresence and has a stable key.
  • Janky layout transitions: avoid animating width/height directly; use layout or transform scale with transform-origin.
  • Shared element mismatch: layoutId must 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:

  • layout enables smooth reflow when the list changes size.
  • AnimatePresence animates removed items with exit.
  • Parent variants stagger tile entrance.

Next steps

  • Build a shared-element route transition with layoutId.
  • Add drag to cards for a playful inbox triage.
  • Use useScroll + useTransform for 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