React Animations with Framer Motion: Variants, Gestures, Exit and Layout Animations
Key takeaways
Framer Motion (now published as Motion) makes React animations declarative: describe states with initial, animate and exit, and let the library interpolate. This guide covers transitions, variants, gestures, AnimatePresence exit animations, page transitions, FLIP-based layout animations and scroll effects, along with the transform-overwrite, App Router and reduced-motion pitfalls that come up in real projects.
CSS transitions handle simple hover and state changes, but they cannot animate an element that React is removing from the tree, and gesture-driven or layout-changing animations quickly turn into manual bookkeeping. Framer Motion makes these declarative: you render a motion component and describe states through props such as animate, exit, and layout. This post covers basic transitions, variants, hover/tap/drag gestures, exit animations with AnimatePresence, page transitions in Next.js, layout animations, scroll-driven effects, and performance considerations.
When to Choose Framer Motion
It is a good fit for:
- Interactive animations driven by gestures (drag, hover, tap)
- Exit animations for modals, toasts, and route changes
- Layout and shared-element animations
- Page transitions in React frameworks such as Next.js
Plain CSS is often enough for simple hover states. For React Native, Reanimated is the more common choice, and if bundle size is the main constraint, compare the cost against a lighter library or CSS.
Installation
npm install framer-motion
In late 2024 the library was spun out of Framer and renamed Motion. The new package is motion, and React code imports from motion/react (import { motion, AnimatePresence } from 'motion/react'). The framer-motion package continues to be published with the same API, so every example in this post works with either import; for a new project, the motion package is the one the documentation now uses.
For the Next.js App Router, remember that motion components use hooks and browser APIs, so any file that renders them needs 'use client' at the top. A common pattern is to keep pages as Server Components and move only the animated pieces into small client components, which keeps the animation code out of the server bundle and avoids turning whole pages into client components.
Basic Animation
Replace any HTML element with its motion equivalent:
import { motion } from 'framer-motion'
// Animate on mount
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
Hello World
</motion.div>
// Any HTML element
<motion.h1 animate={{ scale: 1.1 }} />
<motion.button whileHover={{ scale: 1.05 }} />
<motion.img animate={{ rotate: 360 }} />
initial describes the state the element starts in on its first render, and animate describes the target. Whenever the value of animate changes on a later render, Motion animates from the current visual value to the new one, so animate={{ x: isOpen ? 200 : 0 }} is all you need for a toggle; there is no “play” call. If you want an element to start in its final state without animating on mount (for example, content that was server-rendered already visible), pass initial={false}.
Values like x, y, scale and rotate are shorthand for individual transform functions. Motion composes them into one transform string on the element, which is why you can animate x and scale independently without writing transform: translateX(...) scale(...) yourself. Plain numbers are pixels for x/y, but strings with units ('50%', '2rem') also work. Because Motion writes the whole transform property, anything you put in style.transform yourself is overwritten, a trap the modal example below runs into.
Transition Configuration
// Spring physics (natural feel)
<motion.div
animate={{ x: 100 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
/>
// Tween (controlled timing)
<motion.div
animate={{ opacity: 1 }}
transition={{ type: 'tween', duration: 0.3, ease: 'easeOut' }}
/>
// Delay and repeat
<motion.div
animate={{ y: [0, -10, 0] }} // keyframes
transition={{ repeat: Infinity, duration: 1, ease: 'easeInOut' }}
/>
// Stagger children (via parent)
<motion.ul
initial="hidden"
animate="visible"
variants={{
visible: { transition: { staggerChildren: 0.1 } }
}}
>
{items.map(item => (
<motion.li
key={item.id}
variants={{
hidden: { opacity: 0, x: -20 },
visible: { opacity: 1, x: 0 }
}}
/>
))}
</motion.ul>
The two transition types feel different for a reason. A tween interpolates over a fixed duration with an easing curve, so it is predictable and good for opacity fades and color changes. A spring simulates a physical spring: stiffness controls how strongly it pulls toward the target, damping how quickly oscillation dies out, and mass how heavy the element feels. There is no fixed duration; the animation ends when the spring settles. Springs are the default for physical properties like x and scale because they handle interruption naturally: if the target changes mid-animation, a spring continues from its current velocity instead of restarting, which is what makes a quickly re-toggled menu look smooth rather than jumpy.
repeat: Infinity on a keyframe array is fine for small loading indicators, but an animation that never stops keeps the animation loop running on every frame. Pause it when the element is off screen or when the user has requested reduced motion.
Variants
Variants define named animation states and let you propagate animation to children:
const cardVariants = {
hidden: { opacity: 0, y: 30 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: 'easeOut' }
},
hover: { scale: 1.02, boxShadow: '0 10px 30px rgba(0,0,0,0.1)' },
tap: { scale: 0.98 },
}
<motion.div
variants={cardVariants}
initial="hidden"
animate="visible"
whileHover="hover"
whileTap="tap"
>
<h2>Card Title</h2>
</motion.div>
Variants replace inline animation objects with labels. The real benefit shows up with nesting: when a parent’s animate is set to a variant name, every motion child that defines a variant with the same name animates to it too, without its own animate prop. That propagation is what makes the stagger example in the previous section work. The parent’s visible variant carries staggerChildren: 0.1, and each li only knows what hidden and visible look like for itself. Orchestration options such as delayChildren, staggerChildren and when: 'beforeChildren' belong in the parent’s transition, not the children’s.
One gotcha: propagation stops at any child that sets its own animate prop, because an explicit animate takes precedence over the inherited label. If a nested list suddenly stops staggering after a refactor, check for a stray animate on the children. Also note that the hover variant here animates boxShadow, which is repainted on every frame; on a grid of dozens of cards this can drop frames on low-end devices. A common workaround is to put the shadow on a pseudo-element or a separate layer and animate its opacity instead.
Gesture Animations
// Hover and tap
<motion.button
whileHover={{ scale: 1.05, backgroundColor: '#3b82f6' }}
whileTap={{ scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
Click me
</motion.button>
// Drag
<motion.div
drag
dragConstraints={{ left: -100, right: 100, top: -100, bottom: 100 }}
dragElastic={0.2}
whileDrag={{ scale: 1.1, cursor: 'grabbing' }}
style={{ cursor: 'grab', width: 100, height: 100, background: '#3b82f6' }}
/>
// Drag with snap back
<motion.div
drag
dragConstraints={{ left: 0, right: 0, top: 0, bottom: 0 }}
// dragConstraints = same as origin → snaps back
/>
Gesture props are more than shortcuts for event handlers. whileHover and whileTap apply a temporary animation state that is removed when the gesture ends, and Motion handles the cases that are tedious to get right by hand: a tap that starts on the button and ends outside it is not treated as a click, and whileHover ignores touch devices’ emulated hover so buttons do not get stuck in the hover state after a tap on mobile. whileTap also responds to the keyboard (Enter) on focusable elements, which keeps the feedback accessible.
Drag is implemented with pointer events and applied through the x and y transforms, so the element’s layout position never changes; only its visual offset does. That is important when you read positions later: getBoundingClientRect() reflects the drag, but the element still occupies its original slot in the document flow. dragConstraints accepts either pixel limits, as here, or a ref to a container element, which is usually what you want for “keep it inside this box”. dragElastic controls how far the element can be pulled past its constraints before springing back (0 is rigid, 1 is fully elastic). If you need to react to where the user dropped something, use onDragEnd, whose info argument includes the offset and velocity, rather than reading the DOM.
AnimatePresence (Exit Animations)
import { AnimatePresence, motion } from 'framer-motion'
import { useState } from 'react'
function Modal({ isOpen, onClose }) {
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)' }}
/>
{/* Modal */}
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ type: 'spring', duration: 0.3 }}
style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}
>
<p>Modal content</p>
<button onClick={onClose}>Close</button>
</motion.div>
</>
)}
</AnimatePresence>
)
}
The reason AnimatePresence exists is that React has no concept of “about to be removed”. When isOpen becomes false, React would normally unmount the backdrop and modal immediately. AnimatePresence keeps track of its children between renders; when a child disappears, it keeps rendering the old element, runs every exit animation inside it, and only then lets React remove it. That is also why the key props matter: they are how AnimatePresence tells which child left.
This modal has a positioning bug that I have seen in many codebases. The modal is centered with transform: 'translate(-50%, -50%)' in style, but it also animates scale and y, and Motion writes the entire transform property itself. The centering translate is overwritten on the first frame, so the modal’s top-left corner ends up at the center of the screen. There are two clean fixes: let Motion own the offset by using x: '-50%' in style and animating y between '-45%' and '-50%', or center with a full-screen flex container (display: 'flex', alignItems: 'center', justifyContent: 'center') and keep the animated element free of positioning transforms. The second is usually easier to maintain. For production modals, also remember the parts animation does not give you: focus trapping, closing on Escape, and role="dialog" with aria-modal.
// Toast notifications
function ToastList({ toasts }) {
return (
<div style={{ position: 'fixed', bottom: 20, right: 20 }}>
<AnimatePresence>
{toasts.map(toast => (
<motion.div
key={toast.id}
initial={{ opacity: 0, x: 100, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100 }}
layout // smooth reorder when toasts are added/removed
>
{toast.message}
</motion.div>
))}
</AnimatePresence>
</div>
)
}
The layout prop on each toast solves the second half of the problem. When one toast exits, the others would normally jump into the freed space; with layout, Motion measures each element before and after the change and animates the difference. For that to look right while a toast is still animating out, the exiting element should stop taking up space. AnimatePresence mode="popLayout" does exactly that: it pops exiting children out of the layout flow (with absolute positioning) so the remaining items can start moving immediately instead of waiting for the exit to finish. Using toast.id as the key is essential here; keying by array index would make React think the last toast was removed every time, animating the wrong element out.
Page Transitions (Next.js)
// components/PageTransition.jsx
import { motion, AnimatePresence } from 'framer-motion'
import { useRouter } from 'next/router'
const pageVariants = {
initial: { opacity: 0, x: -20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 20 },
}
export function PageTransition({ children }) {
const { pathname } = useRouter()
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
>
{children}
</motion.div>
</AnimatePresence>
)
}
This component is written for the Pages Router (next/router) and is typically wrapped around <Component {...pageProps} /> in _app.js. Changing the key to the new pathname tells AnimatePresence that the old page left and a new one arrived, and mode="wait" makes the new page wait until the old one finishes its exit, so the two never overlap. The trade-off is added latency: with a 0.2s exit, every navigation feels 0.2s slower, so keep page exits short.
The App Router is harder. Layouts persist across navigations and the router swaps page segments itself, so by the time a client component sees the new usePathname(), React has already replaced the old page’s content, and there is nothing left for exit to animate. Workarounds exist (freezing the previous router context, or using a template.js file, which remounts on navigation and can run enter animations), but reliable exit transitions are not something the App Router supports directly. Enter-only animations in template.js are the pragmatic choice there, and the browser’s View Transitions API is increasingly used for cross-page effects instead.
Layout Animations
Automatically animate size and position changes — no manual calculations:
import { motion, LayoutGroup } from 'framer-motion'
import { useState } from 'react'
function Accordion() {
const [isOpen, setIsOpen] = useState(false)
return (
<motion.div layout onClick={() => setIsOpen(!isOpen)} style={{ overflow: 'hidden' }}>
<motion.h3 layout>Click to expand</motion.h3>
{isOpen && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
Hidden content revealed with smooth height animation
</motion.p>
)}
</motion.div>
)
}
// Shared layout (element morphs between positions)
function TabList() {
const [activeTab, setActiveTab] = useState('home')
const tabs = ['home', 'about', 'contact']
return (
<LayoutGroup>
<div style={{ display: 'flex', gap: 8 }}>
{tabs.map(tab => (
<button key={tab} onClick={() => setActiveTab(tab)} style={{ position: 'relative' }}>
{tab}
{activeTab === tab && (
<motion.div
layoutId="active-tab" // same layoutId = shared layout animation
style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'blue' }}
/>
)}
</button>
))}
</div>
</LayoutGroup>
)
}
Layout animations use a technique usually called FLIP (First, Last, Invert, Play). When a layout component re-renders, Motion measures its bounding box before the DOM update and after it, applies a transform that makes the element look like it is still in the old position and size, and then animates that transform back to zero. Because only transform is animated, even a height change from 40px to 300px runs on the compositor instead of triggering layout on every frame.
The cost of that trick is distortion. Changing size is done with scale, so text and borders inside a resizing element would stretch during the animation. Motion corrects this for children that also have the layout prop, which is why the h3 in the accordion is marked layout too; children without it can look squashed mid-animation. For elements that only move and should never scale, layout="position" animates position only. Border radius and box shadow are also corrected automatically only when they are set through style on the motion component, not through a CSS class.
layoutId goes one step further: two different elements with the same layoutId are treated as the same visual object, so when one unmounts and the other mounts, Motion animates between their positions. That is how the underline in the tab example slides between tabs even though each tab renders its own motion.div. LayoutGroup is only needed when separate components that re-render independently should coordinate their layout animations; within one component like this it is optional, but it does no harm. If you render two tab lists on the same page, give each its own LayoutGroup id or distinct layoutId values, otherwise the underline can fly from one list to the other.
useAnimation Hook
Control animations imperatively:
import { motion, useAnimation } from 'framer-motion'
import { useEffect } from 'react'
function ShakeInput({ hasError }) {
const controls = useAnimation()
useEffect(() => {
if (hasError) {
controls.start({
x: [0, -10, 10, -10, 10, 0],
transition: { duration: 0.4 }
})
}
}, [hasError])
return (
<motion.input
animate={controls}
style={{ borderColor: hasError ? 'red' : 'gray' }}
/>
)
}
Declarative animate works when the animation is a function of state. A shake is different: it is an event (“the submit just failed”), and it should play again on the next failure even if hasError was already true. Imperative controls fit that case. Note the limitation in this example: the effect depends on hasError, so a second failed submit that leaves hasError as true does not shake again. Triggering the shake from the submit handler, or keying the effect on an error counter, fixes that.
In recent versions useAnimation is considered legacy; the recommended API is useAnimate, which returns a [scope, animate] pair: attach scope as a ref, then call animate(scope.current, { x: [...] }, { duration: 0.4 }) or target child selectors inside the scope. It also returns a promise, so sequences are written with await instead of chained callbacks. For accessibility, a shake alone is not an error message; pair it with visible error text and aria-invalid on the input so screen reader users get the same information.
Scroll Animations
import { motion, useScroll, useTransform } from 'framer-motion'
import { useRef } from 'react'
// Scroll progress
function HeroSection() {
const { scrollY } = useScroll()
const opacity = useTransform(scrollY, [0, 300], [1, 0])
const y = useTransform(scrollY, [0, 300], [0, -100])
return (
<motion.section style={{ opacity, y }}>
<h1>Parallax Hero</h1>
</motion.section>
)
}
// Animate when element enters viewport
function FadeInSection({ children }) {
const ref = useRef(null)
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-100px' }}
transition={{ duration: 0.5 }}
>
{children}
</motion.div>
)
}
useScroll returns motion values, special observable values that update outside React’s render cycle. useTransform maps one motion value to another (here, scroll position 0–300px to opacity 1–0), and passing the result through style lets Motion update the element directly on every scroll frame without re-rendering the component. That is the key performance property: a scroll-linked animation built with useState and a scroll listener would re-render on every scroll event, while this version renders once. useScroll({ target: ref, offset: ['start end', 'end start'] }) tracks the progress of a specific element through the viewport instead of the whole page.
whileInView does not need the ref in FadeInSection; it uses an IntersectionObserver on the element internally, so the ref and useRef import can be removed. once: true stops observing after the first reveal, which is usually what you want for content, and the negative margin delays the trigger until the element is 100px inside the viewport.
Performance and Accessibility
A few habits keep Motion-heavy pages smooth. Animate transform and opacity wherever possible and use layout rather than animating width or height directly. Be careful with whileHover on long lists, because each hovered element starts an animation; that is cheap for scale and opacity and expensive for shadows and filters. For bundle size, the full motion component includes every feature; LazyMotion with the lightweight m component lets you load only the features you use (for example domAnimation without drag and layout) and load the rest asynchronously, which noticeably cuts the initial JavaScript for pages that only need simple fades.
Respect the user’s reduced-motion setting. Some people get dizzy or nauseous from large movements such as parallax and slide-ins. Wrapping the app in <MotionConfig reducedMotion="user"> makes Motion skip transform and layout animations for users with prefers-reduced-motion: reduce while keeping opacity changes, and the useReducedMotion() hook lets you tone down specific effects yourself. In my experience this is the most frequently skipped step when adding an animation library, and it costs a single line.
Frequently Asked Questions (FAQ)
Q. Why does my component disappear instantly instead of playing its exit animation?
A. AnimatePresence can only animate children it sees leaving, so it must stay mounted while the conditional inside it changes, as in the modal example where {isOpen && ...} sits inside <AnimatePresence>. If the AnimatePresence itself is rendered conditionally, or its parent unmounts, there is nothing left to run the exit. Each direct child also needs a stable, unique key (toast.id for lists, pathname for page transitions); keys based on array index or regenerated on every render break exit detection.
Related Articles
- React from Usage to Internals: Fiber Reconciliation, Diffing, Hooks and Concurrent Rendering
- Tailwind CSS v4 in Practice