Framer Motion is a production-grade animation library for React—published as motion/react since v11—that provides a declarative API for animating components with springs, tweens, gestures, and layout transitions. Instead of writing requestAnimationFrame loops and interpolation math by hand, you describe target states on a motion component using props like initial, animate, and exit, and the library computes the transition for you. It animates only GPU-composited properties such as transform and opacity by default, which is why well-built Framer Motion interfaces hold 60fps even on mid-range devices. The library also handles cases plain CSS cannot: exit animations through AnimatePresence, shared element transitions with layoutId, scroll-linked effects with useScroll and useTransform, and FLIP-based layout animations. This article is a deep tour of the library as you would actually use it in production—variants and orchestration, gestures, scroll and layout animations, springs versus tweens, and the performance discipline that keeps everything smooth.
One housekeeping note before we start. As of v11, the library publishes its React bindings from the motion/react package. The old framer-motion import still works and re-exports the same API, but new code should import from motion/react. Everything in this article uses that package.
// v11+ recommended import path
import { motion, AnimatePresence, useScroll, useTransform } from 'motion/react';
// Legacy — still valid, re-exports the same symbols
// import { motion } from 'framer-motion';The motion component: initial, animate, exit
Everything starts with the motion component. For any HTML or SVG element there is a motion.* equivalent—motion.div, motion.button, motion.path—that accepts three animation props: initial (the state before mount), animate (the target state), and exit (the state on unmount, which only runs inside AnimatePresence). You describe where the element should be, and Framer Motion figures out how to get there.
import { motion } from 'motion/react';
function Card() {
return (
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
className="card"
>
<h3>Production-grade motion</h3>
<p>Declarative in, declarative out.</p>
</motion.div>
);
}A few things are worth internalizing here. The transition prop controls how the animation runs and can live on the component or be scoped per property. Numeric values interpolate automatically; transforms like x, y, scale, and rotate are first-class shortcuts that compile down to the CSS transform property rather than animating left/top. That single detail—transforms instead of layout properties—is the foundation of Framer Motion performance, and we will come back to it.
You can also drive animation imperatively without changing the initial/animate props by passing an animation controls object, but for the vast majority of UI work the declarative form is cleaner and easier to reason about. Reach for imperative controls only when a sequence must be triggered outside React render flow.
How do variants name states and orchestrate children?
Inline objects are fine for a single element, but they do not scale. Variants let you name animation states and reference them by string. The real power is orchestration: when a parent defines variants and a child references the same variant names, the parent can propagate its state to children and control the timing between them.
import { motion, type Variants } from 'motion/react';
const listVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
// Fire children 80ms apart, after a 150ms head start.
staggerChildren: 0.08,
delayChildren: 0.15,
},
},
};
const itemVariants: Variants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0 },
};
function FeatureList({ items }: { items: string[] }) {
return (
<motion.ul
variants={listVariants}
initial="hidden"
animate="visible"
>
{items.map((item) => (
// No initial/animate needed — the parent propagates state.
<motion.li key={item} variants={itemVariants}>
{item}
</motion.li>
))}
</motion.ul>
);
}Notice that the children carry no initial or animate props. Because the parent sets initial="hidden" and animate="visible", those values flow down to every motion child that declares matching variant keys. The parent transition then uses staggerChildren to offset each child by a fixed interval and delayChildren to hold everything back before the cascade begins. This is how you build the polished "content reveals in sequence" effect without hand-computing a single delay.
Orchestration properties worth knowing on a parent transition:
- staggerChildren — fixed delay between each child animation start.
- delayChildren — a single delay applied before children begin.
- staggerDirection — set to -1 to animate the last child first.
- when — "beforeChildren" or "afterChildren" to sequence parent vs child animations.
How does AnimatePresence animate elements out?
React removes a component from the DOM the instant its state says so, which normally makes exit animations impossible—the node is gone before it can animate. AnimatePresence solves this by keeping the element mounted until its exit animation finishes. Wrap the conditional element, give it a stable key, and define an exit variant.
import { AnimatePresence, motion } from 'motion/react';
import { useState } from 'react';
function Toast() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen((v) => !v)}>Toggle</button>
<AnimatePresence>
{open && (
<motion.div
key="toast"
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="toast"
>
Saved successfully
</motion.div>
)}
</AnimatePresence>
</>
);
}AnimatePresence also has a mode prop. The default lets entering and exiting elements coexist; mode="wait" holds the incoming element until the outgoing one has fully left, and mode="popLayout" pops the exiting element out of the layout flow so surrounding elements can settle immediately. The mode="wait" behavior is exactly what you want for route transitions, where the old page should fade out before the new one fades in.
Route transitions
To animate between routes, wrap your router outlet in AnimatePresence with mode="wait" and give each page a key tied to the current path. When the path changes, React swaps the child, AnimatePresence runs the old page exit, then mounts the new one.
import { AnimatePresence, motion } from 'motion/react';
import { useLocation, useRoutes } from 'react-router-dom';
const pageVariants = {
initial: { opacity: 0, y: 12 },
enter: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -12 },
};
export function AnimatedRoutes() {
const location = useLocation();
const element = useRoutes(routes, location);
return (
<AnimatePresence mode="wait">
<motion.main
key={location.pathname}
variants={pageVariants}
initial="initial"
animate="enter"
exit="exit"
transition={{ duration: 0.25, ease: 'easeInOut' }}
>
{element}
</motion.main>
</AnimatePresence>
);
}The stable, unique key is what makes AnimatePresence work. If two consecutive pages share a key, Framer Motion thinks nothing changed and skips the transition entirely.
How do hover, tap, and drag gestures work?
Framer Motion ships gesture props that behave like animation states but respond to user input. whileHover and whileTap apply a target state for the duration of the interaction and automatically animate back when it ends—no manual event handlers, no cleanup. Because whileTap is a pointer gesture rather than a CSS :active pseudo-class, it works consistently across mouse, touch, and pen.
import { motion } from 'motion/react';
function LikeButton() {
return (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.92 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
className="like-button"
>
Like
</motion.button>
);
}Dragging is just as declarative. Add drag to make an element draggable, constrain it with dragConstraints (a ref to a bounding element or an explicit rect), and tune the resistance past the edges with dragElastic. The onDragEnd callback gives you velocity and offset, which is enough to build swipe-to-dismiss without a gesture library.
import { motion, type PanInfo } from 'motion/react';
function SwipeCard({ onDismiss }: { onDismiss: () => void }) {
function handleDragEnd(_: PointerEvent, info: PanInfo) {
// Dismiss on a fast or far horizontal swipe.
if (Math.abs(info.offset.x) > 120 || Math.abs(info.velocity.x) > 500) {
onDismiss();
}
}
return (
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.6}
onDragEnd={handleDragEnd}
whileTap={{ cursor: 'grabbing' }}
className="swipe-card"
>
Swipe me away
</motion.div>
);
}Scroll animations: useScroll, useTransform, whileInView
There are two flavors of scroll animation. The simplest is whileInView, a viewport-triggered state that behaves like whileHover but fires when the element enters the viewport. Pair it with the viewport prop to control the trigger; once: true ensures it animates only the first time, and amount decides how much of the element must be visible.
import { motion } from 'motion/react';
function RevealSection() {
return (
<motion.section
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.5 }}
>
<h2>Reveals when 30% is visible</h2>
</motion.section>
);
}For scroll-linked effects—where a value tracks scroll position continuously rather than firing once—you compose useScroll with useTransform. useScroll returns motion values such as scrollYProgress (0 to 1 across a target), and useTransform maps that input range onto an output range. The result is a motion value you feed straight into a style prop, so the animation runs off the main React render loop.
import { motion, useScroll, useTransform } from 'motion/react';
import { useRef } from 'react';
function ParallaxHeader() {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start start', 'end start'],
});
// Map scroll progress (0..1) onto visual outputs.
const y = useTransform(scrollYProgress, [0, 1], ['0%', '50%']);
const opacity = useTransform(scrollYProgress, [0, 0.8], [1, 0]);
return (
<div ref={ref} className="header">
<motion.img src="/hero.jpg" style={{ y, opacity }} alt="" />
</div>
);
}The key insight is that motion values do not trigger React re-renders. When scrollYProgress updates, Framer Motion writes the transformed value directly to the DOM node style. A parallax hero built this way stays smooth even on a mid-range phone, because you are not re-rendering a component tree 60 times a second.
How do the layout prop and layoutId work?
Some of the most impressive effects—reordering a list, a card expanding into a modal—involve animating properties that are normally impossible to animate: width, position, flex order. Framer Motion handles these with the FLIP technique (First, Last, Invert, Play). Add the layout prop and the library measures the element before and after a render, then animates the difference using transforms. You change the layout however you like in React; Framer Motion animates the transition for free.
import { motion } from 'motion/react';
import { useState } from 'react';
function ExpandingCard() {
const [expanded, setExpanded] = useState(false);
return (
<motion.div
layout
onClick={() => setExpanded((v) => !v)}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className={expanded ? 'card card--expanded' : 'card'}
>
<motion.h3 layout="position">Tap to expand</motion.h3>
{expanded && <motion.p layout>Extra content revealed on expand.</motion.p>}
</motion.div>
);
}Note layout="position" on the heading: it animates the element position but not its size, which prevents text from stretching or squashing during the transition—a common polish detail. For content that should not distort, layout="position" is almost always what you want.
Shared layout with layoutId
The layoutId prop takes this a step further: it creates a shared layout animation between two different elements that are never mounted at the same time. When one element with a given layoutId unmounts and another with the same layoutId mounts, Framer Motion animates smoothly between them—the basis of the classic "thumbnail grows into a full detail view" transition.
import { AnimatePresence, motion } from 'motion/react';
import { useState } from 'react';
function Gallery({ items }: { items: { id: string; src: string }[] }) {
const [selected, setSelected] = useState<string | null>(null);
return (
<>
<div className="grid">
{items.map((item) => (
<motion.img
key={item.id}
layoutId={item.id}
src={item.src}
onClick={() => setSelected(item.id)}
alt=""
/>
))}
</div>
<AnimatePresence>
{selected && (
<motion.div
className="lightbox"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelected(null)}
>
{/* Same layoutId — animates from the grid thumbnail. */}
<motion.img layoutId={selected} src={findSrc(items, selected)} alt="" />
</motion.div>
)}
</AnimatePresence>
</>
);
}One caveat with layout animations: they can conflict with CSS transforms you apply yourself, and they measure real layout, so wrapping a layout element in a parent that also animates layout needs care. When positions look wrong, check whether a parent should also carry the layout prop, or use a LayoutGroup to coordinate siblings.
When should you use springs vs tweens?
Every transition uses one of two families of physics. A tween interpolates over a fixed duration with an easing curve—predictable, good for opacity fades and precisely timed sequences. A spring is simulated physics with no fixed duration; it settles based on stiffness, damping, and mass. Springs feel organic and are the right default for anything driven by direct interaction (drag release, tap feedback, layout changes), because they carry a sense of weight and momentum.
// Tween: fixed duration, explicit easing.
const fade = { type: 'tween', duration: 0.3, ease: [0.4, 0, 0.2, 1] };
// Spring: physics-based, no duration. Higher stiffness = snappier;
// higher damping = less oscillation; higher mass = heavier feel.
const bouncy = { type: 'spring', stiffness: 500, damping: 25, mass: 1 };
// A critically-damped spring settles without any overshoot.
const smooth = { type: 'spring', stiffness: 200, damping: 30 };A practical rule of thumb: use tweens when you need exact timing or coordination with other timed events, and springs when the motion should respond to a user gesture or reflect physical weight. If a spring oscillates too much, raise damping; if it feels sluggish, raise stiffness; if it feels too light, raise mass.
How do you keep animations at 60fps?
A smooth animation is one that stays on the compositor thread and never forces the browser to recalculate layout mid-frame. Framer Motion makes the right thing easy, but you still have to make the right choices.
The rules that keep animations at 60fps:
- Animate only transform and opacity. These are GPU-composited and never trigger layout or paint. Prefer x/y over left/top, and scale over width/height.
- Avoid animating layout-triggering properties (width, height, top, left, margin) directly. If you must change layout, use the layout prop so Framer Motion converts it into a transform under the hood.
- Let will-change be managed for you. Framer Motion applies will-change during an animation and removes it afterward; manually pinning will-change on many elements wastes GPU memory and can hurt performance.
- Use motion values (useScroll, useTransform, useMotionValue) for continuous animations so updates bypass React re-renders and write straight to the DOM.
- Keep the animated subtree small. Animating a container that forces hundreds of children to re-layout each frame is the most common source of jank.
Respecting prefers-reduced-motion
Accessibility is part of production quality. Some users enable "reduce motion" at the OS level because animation causes them discomfort or nausea. Framer Motion exposes useReducedMotion, a hook that returns true when that preference is set, so you can degrade gracefully—swapping a slide for a simple fade, or disabling parallax entirely.
import { motion, useReducedMotion } from 'motion/react';
function AccessibleReveal({ children }: { children: React.ReactNode }) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: shouldReduceMotion ? 0.2 : 0.5 }}
>
{children}
</motion.div>
);
}For a global switch, wrap your app in MotionConfig with reducedMotion="user"; Framer Motion will then automatically reduce transform-based animations for users who ask for it, while leaving opacity fades intact. It is the smallest possible change for a real accessibility win.
Motion should never be a barrier. If an animation cannot be reduced gracefully, it probably should not have been essential to the experience in the first place.
Key Takeaways
What to carry into your next project:
- Import from motion/react (v11+). The framer-motion path still works, but motion/react is the forward-looking package.
- The motion component with initial, animate, and exit covers most needs; exit only runs inside AnimatePresence.
- Variants plus staggerChildren and delayChildren give you orchestrated, cascading animations without hand-computing delays.
- AnimatePresence keeps elements mounted long enough to animate out; mode="wait" plus a path-based key is the recipe for route transitions.
- Gestures (whileHover, whileTap, drag) are declarative states that auto-reverse—no manual event handling or cleanup.
- Use whileInView for one-shot reveals and useScroll + useTransform for continuous, re-render-free scroll effects.
- Layout animations (layout, layoutId) animate the un-animatable via FLIP; use layout="position" to stop text distortion.
- Prefer springs for interaction-driven motion and tweens for precisely timed sequences.
- Animate only transform and opacity, keep animated subtrees small, and always respect prefers-reduced-motion via useReducedMotion or MotionConfig.
