32blogby StudioMitsu
react9 min read

Framer Motion: React Animation Guide

From fade-ins to scroll-triggered effects and page transitions — practical Framer Motion patterns with working code examples for React.

reactframer-motionanimationCSSUX
On this page

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:

tsx
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 starts
  • animate — target state to animate to
  • transition — duration, easing, delay, etc.

Common Animation Properties

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

tsx
"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

tsx
"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.

tsx
"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:

tsx
"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.

tsx
"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

tsx
"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:

tsx
"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:

tsx
"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

tsx
// ❌ 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

tsx
"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:

PatternUse CaseKey Props
Basic animationFade-in, slideinitial, animate, transition
Hover / tapInteractive feedbackwhileHover, whileTap
VariantsList stagger, coordinated animationsvariants, staggerChildren
Scroll-triggeredContent revealwhileInView, viewport
Mount / unmountModals, toastsAnimatePresence, exit
Sequential controlComplex choreographyuseAnimate
Layout changesTabs, list reorderinglayoutId, 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.