The --motion-spring token in my system uses cubic-bezier(0.34, 1.56, 0.64, 1). It overshoots slightly, it reads as physical, it works. But a cubic bézier can only approximate a spring — it’s four control points describing a cubic curve. A real spring has mass, stiffness, and damping, and the resulting motion is not a cubic curve.
Before getting into why that matters, it helps to understand what the browser is actually doing when it runs any animation at all.
How the browser animates anything
Every CSS transition or animation is the same operation: interpolate between a start value and an end value over time. At each frame, the browser asks: “given that we are t% through this animation, what should the value be right now?”
For a simple opacity from 0 to 1, linear time means t = 0.5 → opacity: 0.5. Easy.
An easing function changes the answer. Instead of mapping time directly to value, it maps time to a modified progress, which then maps to the value. So at t = 0.5, a ease-out curve might return 0.75 — the element is already 75% of the way there even though only half the time has passed.
That’s it. An easing function is a lookup table: given progress from 0 to 1, return a modified progress from 0 to 1.
/* All four animate the same property over the same duration.
The easing function is the only difference. */
transition: transform 700ms linear;
transition: transform 700ms cubic-bezier(0.0, 0, 0.2, 1); /* ease-out */
transition: transform 700ms cubic-bezier(0.4, 0, 1, 1); /* ease-in */
transition: transform 700ms cubic-bezier(0.34, 1.56, 0.64, 1); /* spring */
Notice that ease-in and ease-out both end at exactly the same position — the easing function always returns 1.0 at t = 1. The spring is different: it overshoots past 1.0 during the animation, then settles back. The browser is outputting values greater than 1 mid-flight, which means it’s temporarily animating past the target value.
This is what cubic-bezier can do that linear and the named keywords cannot — its Y axis is unconstrained, so it can go above 1 or below 0.
cubic-bezier is four control points
A cubic bézier curve has four points: P0 (always 0,0), P1, P2, and P3 (always 1,1). You specify P1 and P2 — those are the two values in cubic-bezier(x1, y1, x2, y2).
For cubic-bezier(0.34, 1.56, 0.64, 1):
- P1 is at (0.34, 1.56) — the Y value of 1.56 is what causes the overshoot
- P2 is at (0.64, 1.00) — settling back to the target
That’s it. Four points, one smooth curve, one overshoot. The browser solves the bézier parametrically at each frame.
It feels like a spring. It isn’t one. The shape is fixed — you can’t make it bounce twice, you can’t control mass or stiffness independently, and the relationship between the curve shape and any physical parameter is nonexistent. You’re just pushing control points until it looks right.
This is usually fine. For most UI animation — tooltips, modals, cards bouncing in — the approximation is convincing. The place it falls apart is when you need a specific physical feel: a heavy object settling slowly, a tight spring with multiple bounces, or a critically damped element that snaps to position without any overshoot.
What a real spring does
A real spring is a damped harmonic oscillator. The position at any point in time is:
x(t) = 1 - e^(-ζωt) × (cos(ωdt) + (ζ / √(1-ζ²)) × sin(ωdt))
Three parameters control the shape:
Stiffness — how hard the spring pulls toward the target. Higher stiffness means faster, snappier motion. A stiffness of 400 gets there quickly; 80 drifts in slowly.
Damping — how much the oscillation is suppressed. Low damping means lots of bounces. High damping means it settles without bouncing at all.
Mass — the weight of the object on the spring. Higher mass moves more slowly and overshoots further before settling. In UI terms, mass is what makes something feel heavy vs light.
The ratio of damping to stiffness and mass determines the damping regime:
// Underdamped — zeta < 1, bounces before settling
springToLinear({ stiffness: 200, damping: 8, mass: 1 })
// Critically damped — zeta ≈ 1, fastest settle with no overshoot
springToLinear({ stiffness: 200, damping: 28, mass: 1 })
// Overdamped — zeta > 1, no overshoot but noticeably slow
springToLinear({ stiffness: 200, damping: 80, mass: 1 })
Critically damped is the fastest possible settle without any overshoot. This is what you want for something that needs to feel immediate — a selected state, a focus ring. Underdamped is the bouncy one. Overdamped is what happens when you add too much damping — the element sags toward position like it’s moving through honey.
A cubic bézier can approximate underdamped (that’s what cubic-bezier(0.34, 1.56, 0.64, 1) does). It cannot do critical or overdamped — those are monotonically increasing curves that never overshoot, and a cubic bézier can only produce monotonic behavior if you tune out all the interesting parts.
linear() closes the gap
CSS added linear() in 2023. Unlike cubic-bezier, it accepts an arbitrary list of output values at evenly spaced input positions. You’re literally writing out the lookup table:
/* A crude ease-in by hand */
animation-timing-function: linear(0, 0.04, 0.16, 0.36, 0.64, 1);
/* t=0 t=0.2 t=0.4 t=0.6 t=0.8 t=1 */
More points means a smoother curve. 60 points is indistinguishable from continuous. Which means you can sample the spring equation at 60 evenly spaced time steps and paste the output directly into CSS. No JS at runtime — the values are baked in at authoring time.
The generator:
function springToLinear({ stiffness = 180, damping = 12, mass = 1, samples = 60 } = {}) {
const omega = Math.sqrt(stiffness / mass)
const zeta = damping / (2 * Math.sqrt(stiffness * mass))
const omegaD = omega * Math.sqrt(1 - zeta ** 2)
const zetaFac = zeta / Math.sqrt(1 - zeta ** 2)
// Find when oscillation falls below threshold — use that as duration
let settleT = 1
for (let t = 0.05; t < 10; t += 0.01) {
const x = 1 - Math.exp(-zeta * omega * t) * (
Math.cos(omegaD * t) + zetaFac * Math.sin(omegaD * t)
)
if (Math.abs(x - 1) < 0.002) { settleT = t; break }
}
const points = Array.from({ length: samples + 1 }, (_, i) => {
const t = (i / samples) * settleT
const x = 1 - Math.exp(-zeta * omega * t) * (
Math.cos(omegaD * t) + zetaFac * Math.sin(omegaD * t)
)
return +x.toFixed(4)
})
return {
easing: `linear(${points.join(', ')})`,
durationMs: Math.round(settleT * 1000),
}
}
Run it once — in a Node script, a build step, or a Vite plugin — and paste the output into your token file. The durationMs it returns is the settle time: how long until the spring is effectively at rest.
cubic-bezier vs physics spring
/* Approximation — four control points */
transition: transform 650ms cubic-bezier(0.34, 1.56, 0.64, 1);
/* Physics — sampled spring equation, stiffness: 180, damping: 12 */
transition: transform 647ms linear(
0, 0.0426, 0.1559, 0.3063, 0.4603, 0.5924, 0.6905, 0.7534,
0.7872, 0.7993, 0.7966, 0.7848, 0.7692, 0.7539, 0.7414,
0.7331, 0.7292, 0.7295, 0.7331, 0.7392, 0.7466, 0.7547,
0.7625, 0.7694, 0.7749, 0.7789, 0.7814, 0.7826, 0.7827,
/* ... */
1
);
The cubic bézier overshoots once and snaps back cleanly — the curve shape forces that. The physics spring shows the actual decaying oscillation: multiple small bounces before settling, each one smaller than the last. At these parameters (stiffness: 180, damping: 12) the difference is subtle. Drop the damping to 6 and the physics spring visibly bounces; the cubic bézier can’t follow.
Parameters → perception
| Want | Stiffness | Damping | Mass | Result |
|---|---|---|---|---|
| Snappy, no bounce | 300 | 30 | 1 | Fast settle, critical-ish |
| Light bounce | 200 | 15 | 1 | One small overshoot |
| Playful, bouncy | 180 | 8 | 1 | 2–3 bounces, fun |
| Heavy object | 120 | 14 | 2 | Slow, weighty, one overshoot |
| Instant, decisive | 400 | 40 | 1 | Near-critical, very snappy |
Stiffness and damping move together — doubling stiffness without touching damping makes the spring bouncier. Increasing damping to compensate keeps the settle speed while reducing oscillation. Mass is the slow-motion knob: higher mass makes everything feel heavier without changing the fundamental spring shape.
Where it breaks down
No fixed duration. A real spring doesn’t have one — it settles when the oscillation decays below a threshold. The generator finds the settle time by iterating through the equation. This means the durationMs value changes every time you change a parameter, which breaks the nice 1:1 mapping to duration tokens (--duration-fast: 150ms). I haven’t found a clean way to reconcile spring physics with a duration scale — they’re fundamentally different models.
Velocity on interruption. If you click a button mid-animation, CSS starts the new transition from wherever the element currently is — but at zero velocity. A real spring would read the current velocity and continue from that momentum. Framer Motion does this. linear() cannot, because the values are baked in at authoring time.
Token verbosity. A 60-point linear() string is around 400 characters. Pasting that into a design token file works but it’s ugly. The approach I’m leaning toward: generate at build time via a PostCSS plugin or Vite transform, store the parameters (stiffness: 180, damping: 12) as the source of truth, output the linear() value as a computed CSS custom property.
Browser support:
| Browser | linear() support |
|---|---|
| Chrome | ✓ 113+ |
| Firefox | ✓ 112+ |
| Safari | ✓ 17.2+ |
| Edge | ✓ 113+ |
Support landed in Safari 17.2 (December 2023), so it’s usable in production now. The fallback is straightforward: put cubic-bezier first, linear() after with @supports:
.card {
transition: transform 650ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
@supports (animation-timing-function: linear(0, 1)) {
.card {
transition: transform 647ms linear(/* ... */);
}
}
The cubic bézier is a reasonable fallback — it’s what the token was before.
When Framer Motion is still the right call
CSS linear() is good for: enter/exit animations, hover states, any motion that doesn’t get interrupted mid-flight and doesn’t need to track a user gesture.
Framer Motion (or the Web Animations API) is still the right call for: drag interactions, gesture-driven animation, anything where the animation needs to read current velocity and continue from it. The spring model in Framer Motion is continuous — it recalculates at every frame from actual physics state. linear() is a precomputed snapshot. For static transitions that play from start to finish, the snapshot is identical. For interactive motion, it isn’t.
The generator as a starting point — copy it, run it in Node, adjust the parameters until the output durationMs and feel match your use case:
const spring = springToLinear({ stiffness: 200, damping: 15, mass: 1 })
console.log(`duration: ${spring.durationMs}ms`)
console.log(spring.easing)
Paste both values into your token file. Replace when you want a different feel — the parameters are human-readable, the linear() output is generated.