Here is the dirty secret of cubic-bezier(): it is a single curve segment. It can overshoot once—that is the springy feel of cubic-bezier(0.175, 0.885, 0.32, 1.275)—but it cannot oscillate. A real spring overshoots, comes back, overshoots less, and settles. Bezier physically cannot draw that shape.

linear() can. Despite the name, it is the most expressive easing function CSS has ever had: you feed it a series of points, and the browser interpolates between them. Sample a real spring equation into points, and you get genuine spring motion with no JavaScript animation library.

From physics to points

/* A damped spring, sampled into linear() */
:root {
  --spring: linear(
    0, 0.36, 0.66, 0.9, 1.06, 1.15, 1.16, 1.12,
    1.05, 0.99, 0.96, 0.97, 0.99, 1.005, 1
  );
}
.card {
  transition: transform 600ms var(--spring);
}

You do not hand-write those numbers. Generators exist—give them mass, stiffness, damping, and they emit the linear() stops. The point is that the output is a plain CSS custom property: shareable, themeable, zero runtime cost.

“Bezier describes a curve. A spring describes a negotiation between momentum and resistance.”

Where springs belong

Springs shine on things that feel physical: cards lifting, sheets sliding in, toggles snapping. They are wrong for opacity fades and colour shifts—oscillating transparency reads as flicker. And keep the settle subtle: a 16% overshoot feels alive; a 40% overshoot feels like jelly. As with every micro-interaction, the best spring is the one users feel but never notice.