motion
Motion is the animation library formerly published as Framer Motion. It has two faces. From "motion" you get a small imperative API for plain JavaScript: animate(target, keyframes, options), plus scroll(), inView(), hover(), press(), and stagger(). From "motion/react" you get the declarative React API: motion.div and friends with initial, animate, exit, whileHover, whileTap and drag props, AnimatePresence for exit animations, and the layout prop that animates an element between two DOM positions it never explicitly moved through. Underneath, both sit on the same engine, which hands off to the browser's Web Animations API when a value is hardware-accelerable and falls back to a JavaScript loop when it is not. That hybrid is the reason it can do springs, layout projection, and scroll-linked effects that CSS transitions cannot, while still keeping simple opacity and transform animations off the main thread.
The default pick for React apps that need real interaction design, and layout animations plus AnimatePresence are things you genuinely cannot rebuild cheaply. Just be honest about whether your fades and hovers actually need 44 KB, and budget for the fact that a new major landed in January 2025 and again in August 2026.
Use it if
- You are animating a React UI and want enter and exit transitions: AnimatePresence is the least painful way to animate a component that is about to unmount, and nothing in CSS gives you that
- You need layout animations: add the layout prop and an element animates between two positions after a reflow, or use layoutId to make one element appear to morph into another across components
- You want springs described in terms you can reason about: transition: { type: 'spring', visualDuration: 0.4, bounce: 0.25 } instead of guessing stiffness and damping numbers
- You need scroll-linked or viewport-triggered effects: useScroll and whileInView in React, scroll() and inView() in vanilla JS, both built on IntersectionObserver and scroll timelines rather than scroll event handlers
- You are on Vue or plain JS and want the same mental model: motion-v and the "motion" entry point share the API shape, so patterns transfer
- Your animations are hover states, fades, and simple slides. CSS transitions and @keyframes cost zero bytes, run off the main thread already, and will not go stale when a major version lands
- You care about bundle size and only need the imperative API. The full "motion" entry gzips to around 44 KB, and even the React tree-shaken path pulls in a chunk of the engine; motion/mini exists precisely because that is too much for many sites, but it drops springs and the JS fallback path
- You want a stable dependency. The major number is already at 13, with 12.0.0 in January 2025 and 13.0.0 on 2026-08-05, and that last one removed the optional @emotion/is-prop-valid support, so styled-components users now have to pass <MotionConfig isValidProp={isPropValid}> themselves
- You are doing timeline-heavy sequencing across many unrelated elements, SVG morphing, or canvas work. GSAP's timeline model handles orchestration better and does not care whether your renderer is the DOM
- You are on React Server Components without care. motion/react is client-only, so every file using motion.div needs 'use client', and importing it into a shared layout accidentally pulls the whole engine into the client bundle
- You need to animate a value that is not a DOM style. This is a DOM animation library; for animating arbitrary JavaScript state you are better off with a small tweening utility
- Some of the polish is paid. Cursor, Ticker, the transition editor, and most of the 330+ examples sit behind the Motion+ membership, so the free docs are thinner than the marketing suggests
Setup reality
npm install motion, and then decide which entry point you are actually importing from, because that choice is most of the setup. "motion" is vanilla JS, "motion/react" is the React components, "motion/mini" is the WAAPI-only build, and "motion/react-m" is the stripped m component used with LazyMotion. Mixing them is the common mistake: importing animate from "motion" inside a React component works but bypasses React's lifecycle, and useAnimate from "motion/react" is what you actually wanted. React 18 or 19 is a peer dependency, declared optional so the vanilla install does not demand it, which means a wrong React version fails at runtime rather than at install. Everything in motion/react is client-side, so Next.js App Router needs 'use client' at the top of any file that touches it. If you are migrating, note the package is a thin re-export of framer-motion internals, so both packages installed at once gives you two copies of the engine and two sets of contexts, and layout animations quietly stop coordinating. On 13.0.0 the @emotion/is-prop-valid integration is gone: pass isValidProp to MotionConfig yourself if unknown props were being filtered for you before.
Patterns
Animate a DOM element without Reactanimate-element-vanilla
import { animate } from "motion"
const controls = animate(
"#box",
{ x: 200, rotate: 90, backgroundColor: "#0368ff" },
{ duration: 0.6, ease: "easeOut" }
)
await controls
controls.stop()The first argument takes a selector, an Element, or an array of them, so animating a list is one call. The returned object is thenable, so you can await completion, but it is not a Promise: calling .stop() after awaiting is a no-op rather than an error.
The declarative React componentreact-motion-component
"use client"
import { motion } from "motion/react"
export function Card() {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="card"
>
Hello
</motion.div>
)
}initial runs once on mount; passing initial={false} skips the mount animation entirely, which is what you want for content that is already on screen at page load. In Next.js App Router the 'use client' directive is mandatory here, and putting a motion component in a shared layout drags the engine into every route's client bundle.
Springs you can actually tunespring-transition
import { motion } from "motion/react"
<motion.div
animate={{ scale: 1.1 }}
transition={{ type: "spring", visualDuration: 0.4, bounce: 0.25 }}
/>
// the older, harder-to-reason-about form
<motion.div
animate={{ scale: 1.1 }}
transition={{ type: "spring", stiffness: 400, damping: 30, mass: 1 }}
/>visualDuration is how long the animation looks like it takes to settle, and bounce is 0 to 1, which is far easier to tune than stiffness and damping. Springs ignore duration and ease, so a transition that mixes type: 'spring' with duration silently drops the duration unless you use visualDuration.
Hover, press, and drag as propsgestures-and-drag
import { motion } from "motion/react"
import { useRef } from "react"
function Draggable() {
const bounds = useRef(null)
return (
<div ref={bounds} className="track">
<motion.div
drag="x"
dragConstraints={bounds}
dragElastic={0.2}
dragSnapToOrigin
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onDragEnd={(_, info) => console.log(info.offset.x, info.velocity.x)}
/>
</div>
)
}drag writes to transform, so the element moves without React re-rendering and your own state never sees it; read position from the info argument or from a motion value instead. dragConstraints against a ref is measured on drag start, so a container that resizes mid-gesture keeps the stale bounds.
Animate a component that is unmountingexit-animations
import { AnimatePresence, motion } from "motion/react"
function Modal({ open }) {
return (
<AnimatePresence mode="wait" initial={false}>
{open && (
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
/>
)}
</AnimatePresence>
)
}The child needs a stable key or AnimatePresence cannot tell a swap from a re-render, and the conditional has to be inside AnimatePresence, not around it. mode='wait' finishes the exit before the next child enters, which is right for route transitions and wrong for lists, where it serializes every removal.
Animate layout changes and morph between elementslayout-animations
import { motion, LayoutGroup } from "motion/react"
// element animates to its new size or position after any reflow
<motion.div layout transition={{ type: "spring", visualDuration: 0.3, bounce: 0 }} />
// same layoutId in two places: the first morphs into the second
<LayoutGroup>
{items.map((item) => (
<motion.li key={item.id} layout>
{item.id === selected && <motion.div layoutId="highlight" className="pill" />}
{item.label}
</motion.li>
))}
</LayoutGroup>Layout animations work by projecting transforms, so children get scaled with the parent unless they also carry layout. Border radius and box shadow distort under that scaling unless set as inline style props on the motion element. Only one element per layoutId may be mounted at a time or the morph target is ambiguous.
Tie an animation to scroll progressscroll-linked
import { motion, useScroll, useTransform } from "motion/react"
import { useRef } from "react"
function Parallax() {
const ref = useRef(null)
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"],
})
const y = useTransform(scrollYProgress, [0, 1], ["0%", "-30%"])
return <div ref={ref}><motion.img src="/hero.jpg" style={{ y }} /></div>
}
// vanilla equivalent
import { scroll, animate } from "motion"
scroll(animate("#hero", { opacity: [1, 0] }), { target: document.querySelector("#hero") })scrollYProgress is a motion value, so passing it through style updates the DOM without re-rendering the component; reading .get() inside render instead gives you a stale number and no updates. The offset strings are element-edge to viewport-edge pairs, and getting them backwards is why parallax often looks inverted.
Drive animation from a value React never re-renders formotion-values
import { motion, useMotionValue, useSpring, useTransform, useMotionValueEvent } from "motion/react"
function Tilt() {
const x = useMotionValue(0)
const smooth = useSpring(x, { visualDuration: 0.3, bounce: 0.2 })
const rotate = useTransform(smooth, [-200, 200], [-15, 15])
useMotionValueEvent(rotate, "change", (v) => console.log(v))
return <motion.div style={{ x: smooth, rotate }} onPointerMove={(e) => x.set(e.clientX - 200)} />
}Motion values live outside React state on purpose: setting one writes straight to the DOM and never triggers a render, which is what makes pointer-driven animation cheap. The cost is that anything else in your component reading x.get() will not update, so subscribe with useMotionValueEvent instead of reading during render.
Stagger a list and sequence multiple animationsstagger-sequence
import { animate, stagger } from "motion"
animate("li", { opacity: 1, y: 0 }, { delay: stagger(0.06, { startDelay: 0.2 }) })
// a sequence, with overlap via `at`
animate([
["#title", { opacity: 1, y: 0 }, { duration: 0.4 }],
["#subtitle", { opacity: 1 }, { duration: 0.3, at: "-0.2" }],
["#cta", { scale: [0.9, 1] }, { at: "<" }],
])at takes a relative offset like '-0.2' to overlap with the previous step, '<' to start with it, or an absolute number in seconds. stagger() returns a function, so it goes in delay, not in a separate option. In React, useAnimate gives you a scoped animate that only touches elements inside its ref.
Name states once and propagate them to childrenvariants
import { motion } from "motion/react"
const list = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.05, delayChildren: 0.1 } },
}
const item = { hidden: { opacity: 0, y: 8 }, show: { opacity: 1, y: 0 } }
<motion.ul variants={list} initial="hidden" animate="show">
{rows.map((r) => <motion.li key={r.id} variants={item} />)}
</motion.ul>Children inherit the variant label from the parent, which is why the li elements need no initial or animate of their own. Propagation stops the moment a child sets its own animate prop, and that silent break is the usual reason a stagger does nothing.
Cut the bundle with mini or LazyMotionreduce-bundle-size
// vanilla: WAAPI only, much smaller, no JS fallback
import { animate } from "motion/mini"
import { spring } from "motion"
animate("#box", { opacity: 1 }, { type: spring, visualDuration: 0.3 })
// React: ship the stripped `m` component, load features lazily
import { LazyMotion, domAnimation } from "motion/react"
import * as m from "motion/react-m"
<LazyMotion features={domAnimation} strict>
<m.div animate={{ opacity: 1 }} />
</LazyMotion>motion/mini only animates what the Web Animations API can, so springs need the generator imported explicitly and independent transforms are limited. LazyMotion with strict throws if you import the full motion component anywhere inside it, which is the only reliable way to stop a teammate undoing the saving. domAnimation excludes layout animations; that needs domMax, which is most of the engine again.
Respect prefers-reduced-motionreduced-motion
import { MotionConfig, useReducedMotion, motion } from "motion/react"
// app-wide: transforms and layout animations are skipped, opacity still animates
<MotionConfig reducedMotion="user">
<App />
</MotionConfig>
// per component, when you want a different treatment rather than none
function Panel() {
const reduce = useReducedMotion()
return <motion.div animate={reduce ? { opacity: 1 } : { opacity: 1, x: 0 }} />
}reducedMotion='user' disables transform and layout animations but deliberately keeps opacity, since fades rarely trigger vestibular problems. This is opt-in: without MotionConfig, Motion animates everything regardless of the OS setting.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| framer-motion | npm | You have an existing codebase on the old package name and do not want a rename churn; it is the same code, and motion depends on it |
| gsap | npm | You are orchestrating long timelines, SVG morphs, or scroll scenes across many elements and want a sequencing model rather than per-component props |
| @react-spring/web | npm | You want spring physics with a hooks-first API and no magic motion components, and you are happy to write the interpolation yourself |
| motion-v | npm | You are on Vue and want the same API shape as Motion for React |