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
An easing curve is a drawing
A CSS transition is described by four numbers and a duration:
That
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.
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
Spring (motion-hover)
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.
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
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
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, from0 to1 . Bounce does not make the animation faster or slower. It controls how much energy is left when the motion reaches the target. At0 the spring is critically damped: it approaches the target and stops without ever crossing it. At1 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:
| Bounce | Feels like | Use for |
|---|---|---|
| Precise, mechanical, calm | Layout shifts, panels, anything large | |
| Alive but professional | Hover lifts, buttons, cards (most UI) | |
| Playful, toy-like | Emphasis moments, empty states, celebrations | |
| Cartoon physics | Almost nothing, on purpose |
The library’s defaults sit in the middle band on purpose.
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
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
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
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
- Motion: the spring engine underneath every component
- motion-hover docs: every attribute, with live previews