Sifen.
all posts
Oct 15, 20259 min readfrontendmotiondesign-engineering

Motion-tuned UI: springs, staggers, and what to never animate

Durations describe how long something takes. Springs describe how it moves. People can tell the difference, even if they can't name it.

Why most motion feels off

Most motion on the web is built with the same instinct that produced PowerPoint transitions. A duration is picked, an easing is chosen from a dropdown, and the developer moves on. The result animates, but it does not feel like anything. The good motion you remember (the iOS share sheet, a Linear keyboard shortcut, a Stripe modal opening) is doing something different. It is using physics, not durations.

The shorthand: durations describe how long something takes; springs describe how something moves. People can tell the difference even if they cannot name it.

Durations are about timing. Springs are about response.

A duration says "this element will move from A to B over 300ms with this easing curve". The motion is a function of the clock. If the user does anything mid-animation (clicks again, drags, hovers somewhere else) the animation either ignores them or jumps.

A spring says "this element wants to be at B; here is how stiff and damped it is". The motion is a function of the current position and velocity. If the user does something mid-animation, the spring picks up the new target and continues from where it actually is. There is no jump. There is no "wait, that is going to finish first".

The cost of a duration is that you are pretending interactions are turn-based. The cost of a spring is that you have to think about stiffness, damping, and mass instead of milliseconds. That second cost is small, and once you have done it for one component, the rest is muscle memory.

A spring vocabulary

The springs I reach for, in Framer Motion notation:

  • { type: "spring", stiffness: 300, damping: 30 } — the default reach. Snappy but settled. Buttons, toggles, small reveals.
  • { type: "spring", stiffness: 180, damping: 24 } — slightly softer. Modal entries, drawer pulls.
  • { type: "spring", stiffness: 120, damping: 20, mass: 1.2 } — feels like something has weight. Cards being thrown around, drag interactions.
  • { type: "spring", stiffness: 500, damping: 35 } — fast and tight. Cursor follows, hover affordances, micro-feedback on click.

Four presets cover most of a site. Make them shared constants. Naming them helps:

export const motion = {
  reach: { type: "spring", stiffness: 300, damping: 30 },
  reveal: { type: "spring", stiffness: 180, damping: 24 },
  weight: { type: "spring", stiffness: 120, damping: 20, mass: 1.2 },
  micro: { type: "spring", stiffness: 500, damping: 35 },
} as const;

Now every animation in the app can pick from the same vocabulary, and changing the feel of "reach" updates buttons, toggles, and reveals at once. That is the kind of consistency you cannot fake with per-component durations.

Where to never animate

Some things should not move. The list is shorter than people realise:

  • Anything triggered by an error. Validation messages, "could not save". Motion makes them feel less serious.
  • Anything that needs to be read. Long-form text, dense tables. Stagger reveals on the headings, not the rows.
  • Anything that follows the cursor and has the wrong easing. A laggy cursor follower is worse than no cursor follower.

The right default for body content is "appear". Not slide, not fade, not scale. Just appear. Then animate the things around it.

Stagger is the difference between "animated" and "designed"

A row of items that all reveal at once feels like a lit-up Christmas tree. The same row, with each item delayed by 40-60ms, feels considered. Framer Motion has staggerChildren for exactly this:

<motion.ul variants={{ show: { transition: { staggerChildren: 0.06 } } }}>
  {items.map((item) => (
    <motion.li
      key={item.id}
      variants={{
        hidden: { opacity: 0, y: 8 },
        show: { opacity: 1, y: 0, transition: motion.reveal },
      }}
    >
      {item.title}
    </motion.li>
  ))}
</motion.ul>

The numbers matter. 60ms between items reads as deliberate. 200ms reads as slow. 20ms is invisible. Pick once, apply everywhere, and your reveals stop looking like a default.

Layout transitions are the cheat code

The single Framer Motion feature that buys the most "this feels like a real product" per line of code is layoutId. When two components share a layoutId, Framer Motion animates the transition between their positions and sizes automatically. Tabs to detail views, gallery to lightbox, list item to expanded card.

<motion.div layoutId={`post-${post.id}`}>
  <h3>{post.title}</h3>
</motion.div>

The expanded version uses the same layoutId. The browser does the rest. There is no cleverness on your side, no manual position math, no "animate this from x to y". The component melts from one shape into the other. The first time you see it work, it is hard to go back.

Performance is a real concern, but not the way you think

The cliché is "use transform, not top". That is true and not the issue. The real performance traps are:

  • Animating on scroll. Scroll fires at very high rates. Anything attached to it has to be cheap or throttled with requestAnimationFrame.
  • Re-rendering the parent on every animation frame. State that changes per frame should live in a ref, not in React state.
  • Animating shadows or filters. They are non-composited and recompute on every frame. Animate opacity instead, or pre-render two variants and cross-fade.

For ninety percent of motion, modern browsers and modern hardware do not care. Worry when you have a real measurement. Until then, focus on the feel.

Respect prefers-reduced-motion

The one thing every motion-heavy site gets wrong: ignoring users who have asked for less of it. The browser tells you. Honor it.

const prefersReduced = useReducedMotion();

<motion.div
  animate={{ opacity: 1 }}
  initial={{ opacity: 0 }}
  transition={prefersReduced ? { duration: 0 } : motion.reveal}
/>;

It is one hook and a ternary per animation. The users who set the flag are not theoretical; they have vestibular sensitivity, motion sickness, or are in a moving car. The cost of forgetting is real, even if you never hear about it directly.

Motion is a finishing layer

The last thing worth saying: motion is the finishing layer, not the foundation. A page that moves beautifully but is hard to read is still hard to read. Get the typography right, get the hierarchy right, get the response times right, then add motion. The springs and the staggers will make the result feel polished. They cannot make it feel right on their own.