Skip to content

Fix: Framer Motion Not Working — Animation Not Playing, Exit Animation Skipped, or Layout Shift on Mount

FixDevs ·

Quick Answer

How to fix Framer Motion issues — variants, AnimatePresence for exit animations, layout animations, useMotionValue, server component errors, and performance optimization.

The Problem

An animation defined with animate doesn’t play:

<motion.div
  initial={{ opacity: 0 }}
  animate={{ opacity: 1 }}
>
  Content
</motion.div>
// Component appears instantly with no fade-in

Or exit animations are skipped when a component unmounts:

{isVisible && (
  <motion.div exit={{ opacity: 0 }}>
    Content
  </motion.div>
)}
// Component disappears instantly — exit animation never runs

Or a layout animation causes a visible jump on first render:

<motion.div layout>
  Content
</motion.div>
// Content shifts position on initial mount before settling

Or using Framer Motion in a Next.js Server Component throws:

Error: useState can only be used in a Client Component.

Why This Happens

Framer Motion’s animation system has specific requirements:

  • Exit animations require AnimatePresence — when a component unmounts, React immediately removes it from the DOM. Framer Motion can’t animate something that’s already gone. AnimatePresence keeps the component mounted until its exit animation finishes.
  • initial is required for animate to have something to transition from — if initial is not set, animate applies immediately with no transition. Setting initial={false} disables the mount animation entirely.
  • Layout animations measure the DOMlayout prop triggers when the component re-renders. On first render, there’s nothing to animate from, which can cause a layout jump if the surrounding content shifts position.
  • Framer Motion uses client-side JavaScriptmotion components use hooks internally. They cannot run in React Server Components (Next.js App Router). You must add "use client" to any file that imports motion.

Fix 1: Write Animations Correctly

import { motion } from 'framer-motion';

// Fade in on mount
<motion.div
  initial={{ opacity: 0 }}    // Start state
  animate={{ opacity: 1 }}    // End state
  transition={{ duration: 0.3 }}
>
  Content
</motion.div>

// Slide in from left
<motion.div
  initial={{ x: -50, opacity: 0 }}
  animate={{ x: 0, opacity: 1 }}
  transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
  Content
</motion.div>

// Skip mount animation (only animate on state changes)
<motion.div
  initial={false}
  animate={{ opacity: isVisible ? 1 : 0 }}
>
  Content
</motion.div>

// Hover and tap animations
<motion.button
  whileHover={{ scale: 1.05 }}
  whileTap={{ scale: 0.95 }}
  transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
  Click me
</motion.button>

// Transition types
<motion.div
  animate={{ x: 100 }}
  transition={{
    type: 'tween',     // Linear or eased
    duration: 0.3,
    ease: 'easeOut',
  }}
/>

<motion.div
  animate={{ x: 100 }}
  transition={{
    type: 'spring',    // Physics-based
    stiffness: 300,
    damping: 20,
    mass: 1,
  }}
/>

<motion.div
  animate={{ x: 100 }}
  transition={{
    type: 'inertia',   // Momentum-based (for drag)
    velocity: 50,
  }}
/>

Fix 2: Fix Exit Animations with AnimatePresence

import { motion, AnimatePresence } from 'framer-motion';

// WRONG — exit animation is ignored without AnimatePresence
function Modal({ isOpen }) {
  return (
    <>
      {isOpen && (
        <motion.div exit={{ opacity: 0 }}>
          Modal content
        </motion.div>
      )}
    </>
  );
}

// CORRECT — wrap conditional rendering with AnimatePresence
function Modal({ isOpen }) {
  return (
    <AnimatePresence>
      {isOpen && (
        <motion.div
          key="modal"           // key is required for AnimatePresence
          initial={{ opacity: 0, scale: 0.9 }}
          animate={{ opacity: 1, scale: 1 }}
          exit={{ opacity: 0, scale: 0.9 }}
          transition={{ duration: 0.2 }}
        >
          Modal content
        </motion.div>
      )}
    </AnimatePresence>
  );
}

// Animating list items (add/remove)
function AnimatedList({ items }) {
  return (
    <AnimatePresence initial={false}>  // initial={false} skips mount animation
      {items.map(item => (
        <motion.div
          key={item.id}              // Stable key is required
          initial={{ opacity: 0, height: 0 }}
          animate={{ opacity: 1, height: 'auto' }}
          exit={{ opacity: 0, height: 0 }}
          transition={{ duration: 0.2 }}
        >
          {item.name}
        </motion.div>
      ))}
    </AnimatePresence>
  );
}

// Page transitions (with React Router or Next.js)
function PageWrapper({ children }) {
  return (
    <AnimatePresence mode="wait">   // mode="wait" finishes exit before enter
      <motion.div
        key={useLocation().pathname}  // Unique per page
        initial={{ opacity: 0, y: 20 }}
        animate={{ opacity: 1, y: 0 }}
        exit={{ opacity: 0, y: -20 }}
        transition={{ duration: 0.25 }}
      >
        {children}
      </motion.div>
    </AnimatePresence>
  );
}

Fix 3: Use Variants for Orchestrated Animations

Variants let you coordinate animations across parent and children:

const containerVariants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.1,  // Each child animates 0.1s after the previous
      delayChildren: 0.2,    // Wait 0.2s before starting children
    },
  },
  exit: {
    opacity: 0,
    transition: { staggerChildren: 0.05, staggerDirection: -1 },
  },
};

const itemVariants = {
  hidden: { opacity: 0, y: 20 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { type: 'spring', stiffness: 300, damping: 25 },
  },
  exit: { opacity: 0, y: -20 },
};

function AnimatedList({ items }) {
  return (
    <AnimatePresence>
      <motion.ul
        variants={containerVariants}
        initial="hidden"
        animate="visible"
        exit="exit"
      >
        {items.map(item => (
          <motion.li key={item.id} variants={itemVariants}>
            {/* variants propagate — no need to repeat initial/animate/exit */}
            {item.name}
          </motion.li>
        ))}
      </motion.ul>
    </AnimatePresence>
  );
}

// Dynamic variants — functions that receive custom data
const cardVariants = {
  offscreen: { y: 100, opacity: 0 },
  onscreen: (i: number) => ({
    y: 0,
    opacity: 1,
    transition: { delay: i * 0.1 },
  }),
};

{items.map((item, i) => (
  <motion.div
    key={item.id}
    variants={cardVariants}
    initial="offscreen"
    whileInView="onscreen"  // Triggers when element enters viewport
    viewport={{ once: true, amount: 0.3 }}
    custom={i}              // Passed to variant functions
  />
))}

Fix 4: Layout Animations

// layout prop — animates position and size changes
function ExpandableCard({ isExpanded, onClick }) {
  return (
    <motion.div
      layout                   // Animate any layout changes
      onClick={onClick}
      style={{ borderRadius: 12 }}  // Keep consistent to avoid jarring
    >
      <motion.h2 layout="position">Title</motion.h2>  // Only animate position
      {isExpanded && (
        <motion.p
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
        >
          Expanded content
        </motion.p>
      )}
    </motion.div>
  );
}

// Shared layout animations (elements that move between positions)
import { LayoutGroup } from 'framer-motion';

function TabBar({ tabs, activeTab, setActiveTab }) {
  return (
    <LayoutGroup>  // Groups layout animations
      <div style={{ display: 'flex' }}>
        {tabs.map(tab => (
          <button key={tab.id} onClick={() => setActiveTab(tab.id)}>
            {tab.label}
            {activeTab === tab.id && (
              <motion.div
                layoutId="underline"   // Same layoutId = shared animation
                style={{ height: 2, background: 'blue' }}
              />
            )}
          </button>
        ))}
      </div>
    </LayoutGroup>
  );
}

Fix 5: useMotionValue and Gestures

import { motion, useMotionValue, useTransform, useSpring, useScroll } from 'framer-motion';

// useMotionValue — a reactive value for performant animations
function DraggableSlider() {
  const x = useMotionValue(0);

  // Transform motion values into other values
  const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0]);
  const background = useTransform(x, [-200, 0, 200], ['#ff0000', '#0000ff', '#00ff00']);

  return (
    <motion.div
      drag="x"            // Enable horizontal drag
      dragConstraints={{ left: -200, right: 200 }}
      style={{ x, opacity, background }}
    />
  );
}

// Scroll-linked animations
function ScrollProgress() {
  const { scrollYProgress } = useScroll();
  const scaleX = useSpring(scrollYProgress, { stiffness: 100, damping: 30 });

  return (
    <motion.div
      style={{
        scaleX,
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        height: 4,
        background: 'blue',
        transformOrigin: '0%',
      }}
    />
  );
}

// Animate on scroll — element enters viewport
function FadeInOnScroll({ children }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 30 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, margin: '-100px' }}
      transition={{ duration: 0.5, ease: 'easeOut' }}
    >
      {children}
    </motion.div>
  );
}

Fix 6: Use with Next.js App Router

// 'use client' is REQUIRED for any component using motion
// app/components/AnimatedCard.tsx
'use client';

import { motion } from 'framer-motion';

export function AnimatedCard({ children }: { children: React.ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
    >
      {children}
    </motion.div>
  );
}

// Server Component — wraps the client component
// app/page.tsx (Server Component — no 'use client')
import { AnimatedCard } from './components/AnimatedCard';

export default async function Page() {
  const data = await fetchData();  // Server-side data fetch

  return (
    <AnimatedCard>
      <h1>{data.title}</h1>
    </AnimatedCard>
  );
}

// Page transitions in App Router
// app/template.tsx — template re-renders on every navigation (unlike layout)
'use client';

import { motion } from 'framer-motion';

export default function Template({ children }: { children: React.ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      transition={{ duration: 0.3 }}
    >
      {children}
    </motion.div>
  );
}

Still Not Working?

Animation plays on first render but not on state changes — check that the value you’re animating actually changes. animate={{ opacity: isVisible ? 1 : 0 }} only animates when isVisible changes. If isVisible is always true, the opacity never changes. Use React DevTools to verify the prop value is actually changing.

AnimatePresence exit animation doesn’t run — the component must be a direct child of AnimatePresence (or a descendant that’s keyed consistently). Wrapping the component in another element between AnimatePresence and the motion element breaks exit tracking. Also ensure each child has a stable key prop — without it, React may recycle the same DOM node instead of unmounting and remounting.

Layout animation causes page-wide reflow — every layout prop triggers a DOM measurement. Many layout animations on the same page can be expensive. Use layout="position" for elements that only move (not resize), and wrap unrelated layout groups in separate LayoutGroup components to scope the measurements.

For related animation libraries, see Fix: React Native Reanimated Not Working and Fix: CSS Scroll Behavior Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles