Fix: Framer Motion Not Working — Animation Not Playing, Exit Animation Skipped, or Layout Shift on Mount
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-inOr exit animations are skipped when a component unmounts:
{isVisible && (
<motion.div exit={{ opacity: 0 }}>
Content
</motion.div>
)}
// Component disappears instantly — exit animation never runsOr a layout animation causes a visible jump on first render:
<motion.div layout>
Content
</motion.div>
// Content shifts position on initial mount before settlingOr 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.AnimatePresencekeeps the component mounted until its exit animation finishes. initialis required foranimateto have something to transition from — ifinitialis not set,animateapplies immediately with no transition. Settinginitial={false}disables the mount animation entirely.- Layout animations measure the DOM —
layoutprop 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 JavaScript —
motioncomponents use hooks internally. They cannot run in React Server Components (Next.js App Router). You must add"use client"to any file that importsmotion.
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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.
Fix: Blurhash Not Working — Placeholder Not Rendering, Encoding Failing, or Colors Wrong
How to fix Blurhash image placeholder issues — encoding with Sharp, decoding in React, canvas rendering, Next.js image placeholders, CSS blur fallback, and performance optimization.
Fix: Embla Carousel Not Working — Slides Not Scrolling, Autoplay Not Starting, or Thumbnails Not Syncing
How to fix Embla Carousel issues — React setup, slide sizing, autoplay and navigation plugins, loop mode, thumbnail carousels, responsive breakpoints, and vertical scrolling.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.