Declarative props cover most interface motion. Reach for hooks when a value has to track something continuously, when motion depends on position in the viewport, or when one animation has to wait for another.
Let A Value Chase Its Target
useSpring takes a value and follows it with spring physics instead of jumping. Use it when the target changes continuously, such as a cursor follower, a progress indicator, or a gauge fed by live data.
const x = useMotionValue(0);
const smooth = useSpring(x, { stiffness: 300, damping: 30 });
The target can change mid-flight and the motion stays continuous, which a duration-based transition cannot do. There is no duration to interrupt, so there is no snap.
- Raise
stiffnessto arrive sooner - Raise
dampingto remove overshoot - Leave a little overshoot on playful interactions, remove it on data displays where the overshoot would misreport a value
Use a plain transition instead when the target is fixed. A spring on a one-shot fade is wasted complexity.
Which spring layer to reach for
- A JS spring is a live simulation, so it carries the value's current velocity into a new target when you retarget it mid-flight. Motion does this by default: the spring's initial velocity defaults to the animating value's current velocity
- react-spring exposes the same capability through
config.velocity, which you set explicitly, most often from a gesture handler. If you need a drag release to hand its momentum to the spring there, wire that value through yourself rather than assuming it is carried - A CSS
linear()spring cannot preserve velocity at all. It is a recording of a spring, not a spring. See spring-easing-in-css for what that costs on interrupt and when the CSS layer is still the right answer
Render A Spring Value As A Number
Both libraries deliberately bypass React state so a running animation does not re-render the tree every frame. The consequence surprises everyone the first time: the hook hands back an object, not a number.
const count = useSpring(0);
count; // MotionValue, not 0
count.get(); // the number, but only at this instant
<span>{count}</span>; // a plain span cannot render it
Reading .get() in render is the trap that looks like it works. It gives you the value at render time, and nothing re-renders when it changes, so the number freezes at whatever it was.
Motion: pass the motion value as the child of a motion component. It writes straight to the text node, with no React render involved. Use useTransform to format it first, since a raw spring value is a long float.
const count = useSpring(0, { stiffness: 120, damping: 20 });
const rounded = useTransform(count, (v) => Math.round(v).toLocaleString());
useEffect(() => { count.set(target); }, [count, target]);
return <motion.span>{rounded}</motion.span>;
react-spring: the same idea through SpringValue.to(), rendered inside an animated component.
const { n } = useSpring({ from: { n: 0 }, to: { n: target } });
return <animated.span>{n.to((v) => Math.round(v).toLocaleString())}</animated.span>;
Neither, no component wrapper available: run a bare animation and write the formatted value into a ref'd node from the update callback. Motion's animate takes two numbers and calls you on every frame.
const el = useRef(null);
useEffect(() => {
const controls = animate(0, target, {
duration: 1.2,
onUpdate: (v) => {
if (el.current) el.current.textContent = Math.round(v).toLocaleString();
},
});
return () => controls.stop();
}, [target]);
return <span ref={el}>0</span>;
Give the node a sensible non-empty initial text so the number is present before hydration and for anyone the animation never runs for. A counter that starts at nothing is a blank cell in a screen reader.
Stop A Bouncy Spring Going Past Zero
Symptom: an element dismissed with a bouncy spring to scale: 0 briefly appears mirrored, or flips, right at the end.
Cause: overshoot is symmetrical. A spring that would sail past 1 on the way up sails past 0 on the way down, into negative numbers, and a negative scale is a reflection. The same applies to any value with a meaningful floor: opacity, height, a radius.
Fix, react-spring: config: { clamp: true } ends the animation the moment it first reaches the target, so it never crosses.
const springs = useSpring({
scale: isOpen ? 1 : 0,
config: { tension: 260, friction: 18, clamp: !isOpen },
});
Fix, Motion: there is no documented clamp option. Two routes that work:
- Set
bounce: 0on the dismissing transition, so that spring has no overshoot to spend - Clamp the rendered value, which leaves the spring alone:
useTransform(scale, (v) => Math.max(v, 0))
Apply either only on the way out. Clamping both directions throws away the entrance bounce, which is the reason you chose a spring.
Pick A Stable Identifier
Shared layout animations and SVG element ids both need a value that is unique on the page and identical on every render. Generating one is where this goes wrong.
- Random in state is a hydration bug.
useState(() => Math.random())runs on the server and again on the client, produces two different values, and React reports a mismatch. Same forcrypto.randomUUID()and a module-level counter, whose order is not guaranteed to match between the two trees - Random is doubly wrong for an id.
Math.random()stringifies to something like0.8321, which starts with a digit and contains a full stop. That is not a valid CSS identifier, sodocument.querySelector("#" + id)throws aSyntaxErrorunless you run the value throughCSS.escapefirst. The SVG reference is the exception that lulls you:url(#0.8321)is a URL fragment reference, matched against the id itself rather than parsed as a selector, so it does resolve. The paint looks right while the query throws useIdis the answer. React derives it from the calling component's position in the tree, so the server and client agree regardless of the order components hydrate in
function Sparkline({ items }) {
const id = useId();
return (
<svg>
<defs>
<linearGradient id={`${id}-fill`} />
</defs>
<rect fill={`url(#${id}-fill)`} />
{items.map((item) => (
<motion.rect
key={`${id}-${item.id}`}
layoutId={`${id}-${item.id}`}
/>
))}
</svg>
);
}
One useId per component, used as a prefix for every id it needs. Calling the hook once per element works but is wasteful, and it makes the shared-prefix rule below harder to see.
The raw value is not safe to hand to a parser. React documents only that the id is unique per useId call and identical on the server and the client. It commits to no serialised shape, and the shape has changed inside a single major version. Values observed from a first useId call, rendered in a browser with no identifierPrefix: :R0: on 19.0, «R0» on 19.1, _R_0_ on 19.2. Those are samples of one render path, not a contract. The string also varies with identifierPrefix, render mode, and the component's position in the tree, so read them as a warning rather than a lookup table.
For a selector, escape it. CSS.escape covers every shape, including the ones that otherwise throw a SyntaxError.
const id = useId();
// In an effect, not in render: document does not exist while the server renders
useEffect(() => {
const el = document.querySelector(`#${CSS.escape(id)}`);
}, [id]);
For a CSS identifier such as view-transition-name, do not derive one from useId at all. Sanitising by stripping characters is not injective: prefixes a:b and ab both reduce to ab, and two elements holding the same view-transition-name cancel the transition silently, with no console error. A collision you cannot see is a worse outcome than the invalid name you were avoiding. useId is also unique only within one React tree, while a view-transition-name has to be unique across the whole document.
Name it from data you already know to be unique instead. A record id behind a literal prefix is valid as an identifier and keeps two components from claiming the same row:
const transitionName = `card-${card.id}`;
That holds while the ids come from a safe alphabet, so uuids, integers and slugs are fine. If yours are not, map them through a table you control rather than stripping characters out of them. And when the transition is not a cross-element morph, view-transition-name: match-element has the browser generate the unique name, so you need no id at all.
Keep using useId for the id itself. Producing the same value on the server and the client is the point of the hook, and escaping costs you none of it.
The corollary that catches people: when two components must reference the same id, do not call useId in each of them. Two calls produce two different ids and the reference dead-ends. Lift one useId to their common parent and pass it down as a shared prefix.
Suppress The Mount Animation
Symptom: an element slides or fades in on page load when nothing has changed yet.
Cause: on a plain motion component, animate is a target to reach on enter as well as on update. With no initial, the start value is read from the element as it renders, the computed style for a style property and the parsed transform for x, scale and the other transforms, and the component animates from there to the target on mount. A sidebar that CSS already places at translateX(-240px), with animate resolving to x: 0, slides in on every reload.
Fix: give the component an initial. The two forms do different things.
initial={false}suppresses the enter animation outright. The component renders straight at theanimatevalues, whatever those resolve to on the first renderinitial={{ x: -240 }}seeds a start value instead. The enter animation still runs, from-240to whateveranimateresolves to on mount, so it suppresses the mount animation only when-240is already where the element belongs. Reach for it when you want the first update to animate from a state you chose
<motion.aside initial={false} animate={{ x: isOpen ? 0 : -240 }} />
This sidebar takes initial={false} because it can mount either open or closed. A fixed initial={{ x: -240 }} would slide it in every time it mounts with isOpen already true.
initial={false} on AnimatePresence is a different prop with a different job: it suppresses the enter animation of children present on the very first render. Setting it there does nothing for a motion component mounted later.
Animate On Entering The Viewport
const ref = useRef(null);
const inView = useInView(ref, { once: true });
<motion.div
ref={ref}
initial={{ opacity: 0, y: 16 }}
animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.5, ease: [0.165, 0.84, 0.44, 1] }}
/>
Set once so the animation does not replay every time the element passes the viewport edge. Without it, scrolling up and down repeatedly fires the animation and the page feels unstable.
Confirm A Press
whileTap holds a state for the duration of the press and releases it on its own, so no state handling is needed.
<motion.button whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.97 }}>
Save
</motion.button>
Keep the press smaller than the resting size and the hover slightly larger. That ordering matches how a physical button behaves, so it reads correctly without the user thinking about it.
Sequence One Animation After Another
When a second animation must not begin until the first has finished, wait for the callback rather than guessing with a delay.
<motion.div
animate={{ opacity: 1 }}
onAnimationComplete={() => setStage("next")}
/>
A hardcoded delay drifts as soon as the duration changes, and it desynchronises entirely when the animation is interrupted. The callback stays correct in both cases.
onAnimationComplete does not cover layout animations. It fires for every animation except those, which is why a layoutId flight appears to finish with no callback and any state waiting on it hangs forever. Layout animations have their own pair, onLayoutAnimationStart and onLayoutAnimationComplete. On an element that does both, wire the one that matches the animation you are actually waiting for, and use both if you are waiting for both.
<motion.li
layoutId={id}
onLayoutAnimationComplete={() => markDelivered(id)}
/>
Where state must advance, do not stake it on any animation callback. Drive it from an effect keyed on the set of in-flight items, with its own cleanup, and keep the callbacks for cosmetic follow-ups where a missed call costs nothing.
Reduced Motion
Motion does not follow the operating system's reduced-motion preference on its own. MotionConfig's reducedMotion prop defaults to "never", so every hook on this page keeps animating until you opt in. Wrap the app once:
<MotionConfig reducedMotion="user">
<App />
</MotionConfig>
With that in place, transform and layout animations are disabled while opacity and colour still animate, so state changes stay legible. For a per-hook decision, useReducedMotion returns the live preference as a boolean and re-renders when it flips: use it to skip the useSpring entirely, or to swap a y offset for a plain fade. The setup, the in-app override, and how to test it are covered in motion-layout-troubleshooting.
The rule that matters more than the wiring: reduced motion removes movement, never content. An initial={{ opacity: 0 }} waiting on an entrance animation that the reduced path never runs leaves the content invisible to exactly the people who asked for less motion. Every resting state must be fully visible with animation off.
Checklist
-
useSpringis used for continuously changing targets, not one-shot transitions - Springs on data displays have no overshoot
- Scroll-triggered animations pass
once - Pressable controls use
whileTaprather than manual press state - Press scales below the resting size, hover slightly above
- Sequencing uses an animation callback, not a matching delay, and layout animations use
onLayoutAnimationComplete - Spring values reach the DOM through a
motioncomponent,.to(), or an update callback, never.get()in render - Springs animating to zero are clamped on the way out so scale never goes negative
- Shared and SVG ids come from
useId, lifted to a common parent when two components share one - Every
animateprop that should not run on load has aninitial -
MotionConfig reducedMotion="user"is set, and every resting state is visible with animation off