← All articles

Why Springs Beat Easing Curves (and What Bounce Actually Means)

CSS easing curves describe where an animation should be at each point in time. Springs simulate how an object moves. That difference seems subtle until a user interrupts an animation. Then it becomes impossible to ignore.

Every motion-* component in motion-components uses a spring rather than an easing curve. That is not a stylistic choice. It gives every interaction continuity when the user changes their mind mid-animation. This article explains the difference, why it matters for interruptions, and how to read the two numbers the library asks for: duration and bounce.

An easing curve is a drawing

A CSS transition is described by four numbers and a duration:

That cubic-bezier is a static curve: a drawing of how progress maps to time. At 50% of the duration, the element will be exactly where the curve says, no matter what. The animation has no state beyond elapsed time.

This works fine as long as nothing interferes. The problem is that users change their minds constantly.

The interruption problem

Imagine a card moving upward when the pointer suddenly leaves. A CSS transition effectively says: “Forget everything. Start a new animation from here.” A spring says: “I’m already moving upward. I’ll use that momentum while heading back.”

The result is the small hitch you have felt on a thousand websites. The element stops dead for a frame, then eases back down as if it had been standing still. Each hitch is small. Together, they are why hover states feel sticky or cheap. Quick, repeated hovers make the problem obvious: the animation keeps restarting instead of flowing.

CSS transition lifted resting pointer leaves stops dead time the restart throws away velocity Spring lifted resting pointer leaves keeps moving time the momentum carries through

Both cards are partway up when the pointer leaves. The left curve gets a hard corner: the new transition starts from a standstill, so the card stops dead for a frame. The right curve has no corner. The spring keeps its upward velocity, drifts a little higher, then turns around and settles in one motion.

See it yourself

Two cards, same visual goal. Hover back and forth between them as fast as you can. Watch the status text below each card. The CSS card shows “restarting” on every reversal. It throws away the current motion and starts over. The spring card shows “continuing” because it picks up from where it is.

CSS transition

Hover me, then leave quickly

Spring (motion-hover)

Hover me, then leave quickly

The CSS card shows “restarting” every time you enter or leave. Each hover change starts a brand-new curve and throws away the current velocity. The spring card shows “continuing” because the simulation retargets from its current state. No restart happens, so there is no hitch.

The animation is slow and the travel is large on purpose. That makes the interruption window wide enough to see the difference clearly. In production you’d use shorter durations. The physics stays the same.

Source

A spring is a simulation

A spring animation does not map time to progress. It simulates a physical system: the element has a position and a velocity. Each frame, the spring applies force toward the target.

At any moment a spring remembers:

  • its current position
  • its current velocity

When the target changes, it keeps both.

A CSS transition remembers only one thing:

  • how much time has elapsed

That is the entire difference.

CSS transition time 0% 25% 50% 75% 100% progress follows a predefined curve Spring frame 1 position · velocity frame 2 position · velocity frame 3 position · velocity target changes simulation continues

Now interrupt it. When the cursor leaves mid-flight, the target changes from “lifted” back to “resting”. The simulation continues from its current position and velocity. The element slows down, turns around, and settles in one continuous motion. There is no restart, because the simulation never stopped.

This is what motion-hover does under the hood via Motion’s spring generator:

Wiggle the cursor across that button as fast as you can. It never stutters. Every retarget inherits the velocity of the motion already in progress. That property is called interruptibility. It cannot be retrofitted onto a cubic-bezier. It falls out of the physics, or it doesn’t exist.

But springs have three scary parameters, right?

The classic complaint about springs is their native parameterization: mass, stiffness, and damping. Nobody knows what stiffness: 210, damping: 20 feels like without running it. Spring tuning has historically meant trial and error.

Motion uses a perceptual parameterization instead. motion-components does too. So does Figma’s Smart Animate spring:

  • duration: how long the motion takes to visually settle, in seconds. Under the hood, it converts into physical spring constants. It is a hint rather than a hard cutoff. A very bouncy spring keeps oscillating slightly past it. You reason in the same unit you already use everywhere else.
  • bounce: how much the spring overshoots, from 0 to 1. Bounce does not make the animation faster or slower. It controls how much energy is left when the motion reaches the target. At 0 the spring is critically damped: it approaches the target and stops without ever crossing it. At 1 it is severely underdamped: it overshoots hard and oscillates before settling.

Two numbers, both with intuitive meaning. If you’ve handed off designs from Figma, you’ve already used this exact model.

What bounce values feel like

Rules of thumb, not laws:

BounceFeels likeUse for
0Precise, mechanical, calmLayout shifts, panels, anything large
0.150.3Alive but professionalHover lifts, buttons, cards (most UI)
0.40.6Playful, toy-likeEmphasis moments, empty states, celebrations
0.7+Cartoon physicsAlmost nothing, on purpose

The library’s defaults sit in the middle band on purpose. motion-hover ships with bounce="0.3". That is where motion registers as physical without calling attention to itself.

See each bounce in action

Click any square to replay its animation. All four scale up with the same duration. The only difference is bounce.

bounce="0"

bounce="0.15"

bounce="0.4"

bounce="0.7"

The 0 square stops dead at its target. The 0.15 square has a subtle settle. The 0.4 square overshoots and bounces back once. The 0.7 square overshoots hard and wobbles before settling. That range, from “barely alive” to “clearly playful”, is the entire tuning space for most UI.

One caveat: bounce belongs to transforms. Overshoot makes sense for position, scale, and rotation, because a physical object can overshoot a location. It makes no sense for opacity. There is no such thing as 108% visible. That is why fade-driven components like motion-reveal expose duration but keep the fade itself smooth, and reserve the spring character for the movement.

The part you don’t have to do

If you’ve used springs via a JS animation library, you’ve written the plumbing: track the running animation, catch the interruption, read out the current velocity, feed it into the next spring. The entire premise of motion-components is that this plumbing lives inside the component. You write:

and interruptibility, velocity transfer, and prefers-reduced-motion handling (animations resolve straight to their end state when the user asks for reduced motion) are already there.

Once you think of animations as simulations instead of timelines, interruption stops being a special case. Changing the target is just another input to the system. The spring is not a feature you configure. It is the substrate everything is built on.

Further reading