Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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 that differ from CSS transitions and from imperative animation libraries:

  • 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.

A second class of bugs comes from React’s rendering model. motion.div is still a React component, so it follows React reconciliation rules. If you change the key prop, React unmounts and remounts the component — Framer Motion treats this as a brand-new element and replays the initial animation, even if nothing visually changed. Conversely, if you do not change the key when the underlying data identity changes, AnimatePresence cannot detect the swap and skips the exit/enter animation.

A third source of confusion is the rename. Framer Motion was renamed to Motion in late 2024 (npm: motion), but the React-specific imports moved to motion/react. If you upgraded blindly from framer-motion@11 to motion@12+ without changing import paths, every animation silently no-ops because the components resolve to the vanilla DOM build instead of the React build. Either pin framer-motion@11 or update imports to motion/react.

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>
  );
}

Framer Motion vs React Spring vs auto-animate vs GSAP vs View Transitions

Animation libraries fall on a spectrum from declarative (describe end state, library handles tweening) to imperative (manually drive each frame). Picking the wrong one for the use case is why animations feel sluggish or fight your component logic.

Framer Motion (Motion) is declarative. You write animate={{ x: 100 }} and it interpolates. It owns the entire lifecycle: mount, update, exit, layout, gestures, scroll-linked. The layout prop and AnimatePresence are the headline features — neither has a clean equivalent in other libraries. The cost is bundle size (around 50 KB minified for the React entry, though Motion 12+ ships a tree-shakable m component that drops it to ~5 KB if you avoid the full feature set).

React Spring is also declarative but physics-first. Instead of duration: 0.3, you describe mass, tension, and friction. The useSpring hook returns animated values, which you spread into animated.div. React Spring handles imperative api.start() updates better than Framer Motion when you need to interrupt and re-target mid-flight (e.g., gesture-driven UIs). It does not have a layout animation or shared-element transition equivalent. Bundle size is comparable.

@formkit/auto-animate is the simplest possible option. Add useAutoAnimate to a parent and any list children that mount/unmount animate automatically with sensible defaults. No key juggling, no AnimatePresence. Bundle is ~3 KB. It cannot do scroll-linked, gesture-driven, or shared-element animations — but for “I just want list reorder to fade smoothly” it ships in 30 seconds.

GSAP is imperative and framework-agnostic. You write gsap.to(ref.current, { x: 100, duration: 0.3 }). GSAP is the workhorse of marketing pages, complex scroll-triggered timelines, and SVG morphing. It does not integrate with React state — you manage refs and call methods. GSAP has the deepest feature set (MorphSVG, DrawSVG, MotionPath) but those plugins were paid until late 2024 when Webflow open-sourced the full suite. For a state-driven component (modal open/close), GSAP is overkill compared to Framer Motion. For a hero animation with 30 chained tweens, GSAP wins. See Fix: GSAP Not Working for setup details.

View Transitions API is the native browser primitive. document.startViewTransition(() => updateDOM()) snapshots the page before and after, then crossfades. It works with any framework or no framework. The catch: as of 2026 it is solid in Chrome and Safari but Firefox support shipped only in late 2025. The cross-document version (animating between full page loads) is even newer. For modern projects that target evergreen browsers, view transitions handle 80% of what layout and AnimatePresence do — without any library. See Fix: View Transitions API Not Working.

Lottie is for designer-driven animations exported from After Effects as JSON. It’s not a competitor — you would use Lottie for the illustrated checkmark and Framer Motion for the modal it lives inside.

Rule of thumb: Framer Motion for layout animations, gestures, and React state-driven UI. React Spring when physics-tuning matters or you need interruptible springs. auto-animate when you just need list reorder to look nice. GSAP for complex timelines and SVG. View Transitions API when you can drop a library entirely.

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.

motion import resolves to the wrong build after Motion 12 upgrade — if you upgraded from framer-motion to motion@12+, change import { motion } from 'motion' to import { motion } from 'motion/react'. The bare motion import targets the vanilla DOM bundle, which uses the same component names but does not connect to React’s render cycle. Symptom: components render correctly but no animation runs.

Animations stutter on mobile but smooth on desktop — Framer Motion animates via JavaScript by default. For transform-only properties (x, y, scale, rotate, opacity), the GPU still composites the result, but the prop calculation runs on the main thread. If you have a heavy main-thread workload (large list virtualization, expensive selectors), animation frames drop. Move expensive work into useDeferredValue or useTransition, or switch the specific animation to CSS via style={{ transition: 'transform 0.3s' }} if it does not need Framer Motion’s full lifecycle.

whileInView triggers immediately on mount — the IntersectionObserver Framer Motion uses fires synchronously if the element is already in the viewport at mount time. This is correct behavior, not a bug — the element IS in view. To delay first trigger, add viewport={{ once: true, amount: 0.5 }} so it requires 50% visibility before triggering.

For related animation libraries, see Fix: React Native Reanimated Not Working, Fix: GSAP Not Working, Fix: auto-animate Not Working, and Fix: View Transitions API 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