Spring animations in React with Motion: a practical guide

How spring physics works in Motion for React, how to pick stiffness, damping and mass, and how to build tilt, magnetic and tab interactions that feel physical.

Moveset team··3 min read

Most interface animation on the web still runs on fixed durations and cubic-bezier curves. That works for fades, but anything the user touches, drags or points at feels better when it is driven by a spring. A spring has no fixed duration: it settles when the physics settles, and it can be interrupted mid-flight without a visible jump. This guide covers how springs work in Motion (the library formerly known as Framer Motion), how to tune them, and three interaction patterns you can paste into a React app today.

Why springs beat durations for interaction

A tween answers the question "how long should this take?". A spring answers "how heavy and how stiff is this object?". The difference matters the moment an animation is interrupted. If a user hovers a card and leaves halfway through, a tween either snaps or restarts from its current value with a brand new curve. A spring simply retargets: it keeps its current velocity and heads toward the new value, which is exactly what a physical object would do.

That velocity preservation is why springs dominate gesture-driven UI on iOS and Android, and why Motion uses spring physics by default for physical properties like x, y and scale.

The three numbers: stiffness, damping, mass

Motion's spring transition takes three physical parameters:

  • stiffness: how strongly the spring pulls toward its target. Higher values feel snappier.
  • damping: the opposing force that removes energy. Low damping means more overshoot and oscillation; high damping means the value glides in without bouncing.
  • mass: how heavy the object is. More mass makes motion slower to start and slower to stop, which reads as weight.

Moveset ships three named presets, and every component uses one of them so the library feels consistent:

ts
// Moveset spring presets (also in tokens/tokens.ts)
export const snappy = { type: "spring", stiffness: 520, damping: 34 } as const;          // toggles, tabs, taps
export const soft   = { type: "spring", stiffness: 220, damping: 26, mass: 1 } as const; // hover, tilt, follow
export const heavy  = { type: "spring", stiffness: 140, damping: 22, mass: 1.4 } as const; // hero reveals, success moments

A useful rule of thumb: small, frequent interactions get snappy, pointer-follow effects get soft, and once-per-page moments get heavy.

Pattern 1: a tilt card that follows the pointer

Pointer-follow effects are where springs earn their keep. The trick is to never animate directly to the pointer. Instead, write the pointer position into a motion value, derive the rotation with useTransform, and smooth it with useSpring. Moveset's TiltCard does exactly this:

tsx
import { TiltCard } from "@/components/moveset/tilt-card";

export function PlanCard() {
  return (
    <TiltCard max={9}>
      <div className="rounded-2xl border p-8">
        <h3>Pro</h3>
        <p>Every component, web and Expo.</p>
      </div>
    </TiltCard>
  );
}

Under the hood the pattern is short enough to read in one sitting:

tsx
const px = useMotionValue(0.5), py = useMotionValue(0.5);
const rx = useSpring(useTransform(py, [0, 1], [max, -max]), soft);
const ry = useSpring(useTransform(px, [0, 1], [-max, max]), soft);
// onPointerMove: px.set(x / width); py.set(y / height)
// onPointerLeave: px.set(0.5); py.set(0.5)  -> springs back to flat

Because motion values update outside React's render cycle, the card can track the pointer at full frame rate without re-rendering your component tree.

Pattern 2: a magnetic button

A magnetic button drifts toward the cursor when it gets close, then springs home. Keep the pull subtle; a strength of around 0.3 of the pointer offset is enough to feel alive without making the target hard to click.

tsx
import { MagneticButton } from "@/components/moveset/magnetic-button";

<MagneticButton strength={0.3} onClick={() => start()}>
  Get started
</MagneticButton>

The component also adds a whileTap scale on the snappy spring, so the press feedback is immediate even though the hover drift is soft.

Pattern 3: animated tabs with a shared indicator

Tabs are the classic layout animation. Motion's layoutId lets one indicator element move between tabs, and Motion measures and animates the change for you. AnimatedTabs wraps that in a controlled component:

tsx
import { useState } from "react";
import { AnimatedTabs } from "@/components/moveset/animated-tabs";

export function BillingTabs() {
  const [tab, setTab] = useState("Monthly");
  return <AnimatedTabs tabs={["Monthly", "Yearly"]} value={tab} onChange={setTab} />;
}

Wrapping the tabs in MotionConfig with a single transition means every child, including the indicator, shares one spring. That is the simplest way to stop a component from feeling like it was animated by three different people.

Respect reduced motion

Every Moveset web component calls useReducedMotion() and swaps travel for a short fade or no animation when the user has asked their OS to reduce motion. Do the same in your own code: keep the state change, drop the movement.

tsx
const reduce = useReducedMotion();
<motion.div animate={{ x: open ? 240 : 0 }} transition={reduce ? { duration: 0 } : soft} />

Checklist

  • Use springs for anything the user drives: hover, drag, toggle, press.
  • Pick from a small set of named presets instead of tuning every component by hand.
  • Animate motion values, not React state, for pointer-follow effects.
  • Use layoutId for indicators that move between elements.
  • Honour prefers-reduced-motion everywhere.

The free Moveset pack includes TiltCard, MagneticButton, AnimatedTabs and AnimatedHero, each with the presets above already wired in.