framer-motion
framer-motion is the React animation library from the Motion project. Instead of writing keyframes or transition classes, you render motion.div (or motion.button, motion.path, and so on) and describe the state you want in props: initial, animate, exit, whileHover, whileTap, whileInView. It interpolates between those states with spring physics or duration-based easing, and it handles the things CSS cannot: animating an element out before React unmounts it, animating layout changes that CSS transitions ignore, and moving a shared element between two different components. Note the naming: the project renamed itself to Motion, the current package is called motion with a motion/react entry point, and framer-motion is the legacy name published from the same repo at the same version.
The default answer for animation in React, and exit plus layout animations alone justify it on any app with real UI transitions. For new code install motion instead of framer-motion, and if all you need is a fade and a hover, write the CSS and save the 60 KB.
Use it if
- You need exit animations: React unmounts the element before CSS can transition it, and AnimatePresence is the standard fix for modals, toasts, and route changes
- You want spring physics and interruptible animations, where a new target retargets from the current velocity instead of restarting a fixed-duration tween
- You need shared element transitions, where a card grows into a detail view: layoutId does this in two props and hand-rolling it means measuring and FLIP-ing yourself
- You have gesture-driven UI (drag to dismiss, pull to refresh, sliders) and want a MotionValue you can read, transform, and spring without re-rendering React on every frame
- You want scroll-linked effects with useScroll and useTransform rather than writing scroll listeners and rAF loops
- You are starting a new project: the maintainers say directly in the README to install motion and import from motion/react, and framer-motion only exists so old codebases keep resolving
- Your animations are hover states, fades, and slide-ins: a full import costs roughly 60 KB gzipped, and CSS transitions plus @keyframes cost nothing and run off the main thread
- You need timeline authoring, SVG morphing, or scroll scrubbing across a site that is not React: GSAP covers far more ground and does not care what renders the DOM
- You expect layout animations to be free: they work by measuring boxes and applying transforms, which distorts text and borders unless you reach for layout="position" and layoutRoot, and they conflict with any CSS transition on the same element
- You want everything documented in the open: the 330+ examples, 100+ tutorials, and premium APIs like Cursor and Ticker sit behind Motion+, a paid one-time membership, so the free docs sometimes end at the API signature
- You are on Vue: this package is React-only and you need the separate motion-v port, which trails the React feature set
Setup reality
npm install framer-motion, or npm install motion for the current name. React 18 or 19 is a peer dependency (declared optional, because the same repo also ships a vanilla JS entry point), and TypeScript types are bundled. Two things catch people immediately. In Next.js App Router every file rendering a motion component needs "use client" at the top, since the whole library depends on hooks and effects; the error you get otherwise is a confusing server-component complaint about function props. And v13 removed the bundled optional @emotion/is-prop-valid dependency, so if you wrap a styled-components or Emotion component with motion() and want unknown props filtered out, you now install @emotion/is-prop-valid yourself and pass it through <MotionConfig isValidProp={isPropValid}>. Beyond that, budget for bundle size: importing the top-level motion component pulls in every feature, and cutting that down means restructuring around LazyMotion and the m component.
Patterns
Animate an element inanimate-on-mount
import { motion } from 'framer-motion';
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
>
Hello
</motion.div>Omitting transition gives you a spring for physical values like x and y, and a tween for opacity and color. In Next.js App Router this file needs "use client" at the top or the build fails on function props crossing the server boundary.
Animate an element out before unmountexit-animation
import { AnimatePresence, motion } from 'framer-motion';
<AnimatePresence mode="wait">
{isOpen && (
<motion.div
key="dialog"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
>
<Dialog />
</motion.div>
)}
</AnimatePresence>This is the reason most teams install the library at all, since CSS cannot animate an element React has already removed. The child needs a stable key, and AnimatePresence has to stay mounted itself, so putting the conditional above it instead of inside it silently disables the exit. mode="wait" makes the outgoing element finish before the incoming one starts.
Stagger a list with variantsvariants-stagger
const list = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
};
const item = {
hidden: { opacity: 0, x: -8 },
show: { opacity: 1, x: 0 },
};
<motion.ul variants={list} initial="hidden" animate="show">
{rows.map((r) => (
<motion.li key={r.id} variants={item}>{r.label}</motion.li>
))}
</motion.ul>Variant labels propagate down the tree automatically, so children need variants but not their own initial or animate. Propagation stops the moment a child sets animate explicitly, which is the usual reason a stagger does nothing.
Hover, tap, and in-view statesgesture-states
<motion.button
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.97 }}
whileFocus={{ outlineColor: '#4f46e5' }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
>
Save
</motion.button>
<motion.section
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true, amount: 0.3 }}
/>whileHover uses pointer events, so it does not stick on touch devices the way CSS :hover does. whileInView is backed by IntersectionObserver; without viewport={{ once: true }} the animation replays every time the element scrolls back into view.
Animate layout changeslayout-animation
<motion.div layout transition={{ type: 'spring', stiffness: 300, damping: 30 }}>
{expanded ? <FullDetail /> : <Summary />}
</motion.div>
// text-heavy boxes: animate position only, not scale
<motion.div layout="position">{label}</motion.div>Layout animation measures before and after, then interpolates with transforms, so a box changing width visibly stretches its text and border radius during the animation. layout="position" avoids the stretch by animating only the offset. Any CSS transition on the same element will fight the transform and produce jitter.
Move an element between two componentsshared-element
// in the grid
<motion.div layoutId={`card-${id}`} onClick={() => select(id)}>
<Thumb />
</motion.div>
// in the expanded view, rendered somewhere else entirely
{selected && (
<motion.div layoutId={`card-${selected}`}>
<FullCard />
</motion.div>
)}Only one element with a given layoutId may be mounted at a time; two live copies makes the transition jump. Wrap both in LayoutGroup if they live in separate trees that re-render independently.
Drive a value from scroll positionscroll-linked
import { useScroll, useTransform, motion } from 'framer-motion';
const ref = useRef(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'end start'],
});
const opacity = useTransform(scrollYProgress, [0, 0.4, 1], [0, 1, 0]);
<motion.div ref={ref} style={{ opacity }} />scrollYProgress is a MotionValue, so passing it through style updates the DOM directly without re-rendering the component. Reading it with .get() inside render gives you a stale value; use useMotionValueEvent if you need to react in JS.
Smooth a value with a springmotion-value-spring
import { useMotionValue, useSpring, useTransform } from 'framer-motion';
const x = useMotionValue(0);
const smoothX = useSpring(x, { stiffness: 200, damping: 30 });
const rotate = useTransform(smoothX, [-200, 200], [-15, 15]);
<motion.div style={{ x: smoothX, rotate }} onPointerMove={(e) => x.set(e.clientX - 200)} />MotionValues live outside React state, so updating one at pointer-event rate costs no re-renders. That also means nothing in your component tree knows the value changed, so anything derived from it must go through useTransform or useMotionValueEvent.
Drag with bounds and snap-backdrag-constraints
const container = useRef(null);
<div ref={container}>
<motion.div
drag
dragConstraints={container}
dragElastic={0.15}
dragMomentum={false}
onDragEnd={(event, info) => {
if (info.offset.x < -120) dismiss();
}}
/>
</div>dragConstraints can be a ref or an object of pixel bounds; the ref form re-measures on layout change but has historically needed care when the container scrolls. info.offset is the distance from where the drag started, while info.point is viewport coordinates, and mixing them up is the usual swipe-to-dismiss bug.
Animate imperatively in a sequenceimperative-animate
import { useAnimate, stagger } from 'framer-motion';
const [scope, animate] = useAnimate();
async function shake() {
await animate(scope.current, { x: [0, -8, 8, 0] }, { duration: 0.3 });
await animate('li', { opacity: 1 }, { delay: stagger(0.05) });
}
<ul ref={scope}>{items}</ul>Selector strings passed to animate are scoped to the ref, so 'li' only matches inside that subtree. useAnimate works on plain DOM elements too, which is the escape hatch when you cannot convert a third-party component into a motion component.
Ship less JavaScript with LazyMotionreduce-bundle-size
import { LazyMotion, domAnimation, m } from 'framer-motion';
<LazyMotion features={domAnimation} strict>
<m.div animate={{ opacity: 1 }} />
</LazyMotion>
// layout animations and drag need the bigger bundle:
// <LazyMotion features={() => import('./features').then((r) => r.default)} />m is the same component with no features baked in, so importing motion anywhere in the app defeats the whole exercise; strict makes that a runtime error instead of a silent regression. domAnimation excludes layout animations and drag, which live in the larger domMax feature set.
Honour the reduced-motion preferencerespect-reduced-motion
import { MotionConfig, useReducedMotion } from 'framer-motion';
<MotionConfig reducedMotion="user">
<App />
</MotionConfig>
function Card() {
const reduce = useReducedMotion();
return <motion.div animate={{ y: reduce ? 0 : -8, opacity: 1 }} />;
}reducedMotion="user" disables transform and layout animations while still allowing opacity and colour changes, which keeps state changes visible. It does not touch animations you drive yourself from a MotionValue, so check useReducedMotion in those paths too.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| motion | npm | Any new project: same library, current name, and the entry point the docs are written against |
| gsap | npm | Timeline choreography, SVG morphing, or scroll scrubbing on pages that are not built with React |
| react-spring | npm | You want spring-based animation with a hooks-first API and no magic motion.* element wrappers |
| @formkit/auto-animate | npm | You only want list add, remove, and reorder to animate, and you want it in one line with nothing to learn |