Approximating Spring Physics with CSS Easing
Part of Timing Functions & Easing Curves in Core CSS Animation Fundamentals.
Problem: cubic-bezier cannot bounce
Spring motion feels alive because it overshoots its target and settles through a decaying wobble. Designers reach for it on toggles, sheets, and playful confirmations. The instinct is to reproduce it with a cubic-bezier, but a cubic curve has only two control handles and is structurally monotonic — it can overshoot the destination once and ease back, yet it can never cross the target repeatedly the way a damped spring does. If the design calls for a genuine multi-bounce settle, or for motion that reacts to being interrupted mid-flight, an easing curve alone will not get you there.
Root cause: an easing curve is a fixed time→progress map
A CSS timing function maps normalised time to normalised progress and is evaluated once per compositor frame. It is a precomputed shape with no notion of velocity, mass, or state. A real spring is a differential equation: its position at any instant depends on the velocity it currently carries, which is why a spring that is grabbed and released mid-animation continues smoothly from its live velocity. A curve knows none of that; it always replays the same shape from 0 to 1 regardless of what the element was doing when the animation started.
That leaves two honest options. For a fixed spring — one whose shape is decided at author time and always plays the same way — you can sample the damped-spring equation at many points and emit those samples as a piecewise-linear easing using the linear() function. linear() (CSS Easing Level 2) accepts a list of output values with optional input percentages, so a curve with dozens of stops can trace the oscillation and decay of a spring closely enough to be indistinguishable in UI motion. For a dynamic spring — velocity carry-over on interruption, a moving target, runtime-dependent bounce count — you need a spring solver running in JavaScript, ideally driving the Web Animations API so playback stays on the compositor. Whichever path you choose, keep the animated property on transform or opacity so the easing is sampled on the compositor thread and never touches layout.
Decision steps: curve, linear(), or JavaScript
Work top to bottom and stop at the first match.
| Requirement | Approach | Why |
|---|---|---|
| A single gentle overshoot, then settle | cubic-bezier with y2 > 1 |
One control handle above 1 gives exactly one overshoot; widely supported |
| A fixed multi-bounce settle that always plays the same | linear() sampled from a damped spring |
Piecewise-linear stops can trace oscillation and decay; compositor-safe |
| Motion interrupted mid-flight must keep its velocity | JS spring solver + WAAPI | Only a live solver carries current velocity into the next segment |
| Target position changes while animating | JS spring solver + WAAPI | A precomputed curve cannot retarget; it always ends where it was authored |
| Bounce count / stiffness depends on user input at runtime | JS spring solver | Author-time sampling cannot know runtime parameters |
For the mathematics of shaping the single-overshoot cubic-bezier itself, see how to calculate cubic-bezier for natural motion.
Production pattern: linear() spring with a cubic-bezier fallback
The block below defines a reusable spring easing as a custom property. Modern engines use the sampled linear() curve; older engines fall back to an overshooting cubic-bezier via @supports. The animation itself only touches transform, so sampling stays on the compositor thread.
/* Fallback first: a single-overshoot cubic-bezier for engines without linear().
y2 = 1.3 pushes the curve past the target once before settling. */
:root {
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Progressive enhancement: a damped-spring curve sampled into linear() stops.
Each stop is the spring's position at that fraction of the duration; the
values cross 1 several times (overshoot) with a decaying amplitude. */
@supports (transition-timing-function: linear(0, 1)) {
:root {
--ease-spring: linear(
0, 0.128 8.1%, 0.552 20.1%, 0.884, 1.09 35.5%, 1.148, 1.146,
1.096 51.6%, 1.038, 0.99 65.2%, 0.966, 0.966, 0.982 83.7%,
1.001, 1.006, 1.001
);
}
}
.sheet {
transform: translateY(0);
transition: transform 520ms var(--ease-spring);
}
.sheet[data-open="true"] {
transform: translateY(-16px);
}
/* Reduced motion: no overshoot, no wobble — a short, flat settle instead. */
@media (prefers-reduced-motion: reduce) {
.sheet {
transition: transform 120ms linear;
}
}
Rendering Impact:
transformwithlinear()/cubic-beziereasing — composite only. The easing is sampled on the compositor thread; layout and paint are never involved.
To generate the linear() stops, evaluate a damped harmonic oscillator — 1 − e^(−ζωt)·cos(ω_d·t) for your chosen stiffness ω and damping ζ — at even intervals across the duration and emit each sample as a stop. Denser sampling near the overshoot peaks keeps the curve faithful; twenty to forty stops is usually ample for UI-scale motion.
Verification checklist
Constraints and trade-offs
- A
linear()spring is precomputed and fixed: it cannot carry velocity across an interruption or retarget mid-animation. Those cases require a JavaScript spring solver. - More
linear()stops mean a more faithful curve but a longer value string; find the smallest stop count that hides the piecewise segmentation at your duration. - Overshoot on
transform: translateorscaleis compositor-safe, but overshoot that visually implies a size change tempts authors toward animatingwidth/height— keep it ontransformto avoid layout thrashing. - Spring overshoot is spatial motion and can be uncomfortable for vestibular-sensitive users; always provide the reduced-motion settle and keep amplitude conservative.
linear()ships in Chrome 113+, Firefox 112+, and Safari 17.2+; treat it as enhancement over thecubic-bezierbaseline.
Frequently asked questions
Can cubic-bezier produce a spring bounce?
A cubic-bezier can express a single overshoot by pushing its second control point’s Y value above 1, but it cannot oscillate. A cubic curve is monotonic in its control structure, so it can overshoot the target once and settle, but it cannot cross the target multiple times the way a real damped spring does.
When do I need JavaScript instead of CSS easing for a spring?
Use JavaScript or the Web Animations API when the spring must respond to interruption with correct velocity carry-over, when the target changes mid-flight, or when the number of oscillations depends on runtime input. A precomputed linear() curve is fixed at author time and cannot adapt to a velocity it did not know about.
Related
- Timing Functions & Easing Curves — the parent guide on easing and the compositor sampling model
- How to Calculate cubic-bezier for Natural Motion — shaping the single-overshoot fallback curve by hand
- Accessible Motion Architecture — safe amplitudes and reduced-motion settles for springy motion