Spring and Bounce Easing With linear(): Physical Motion Without JavaScript

Motion that overshoots its target and settles back feels physical: a panel that springs open, a toggle knob that lands with a little recoil, a notification that bounces once into place. For years this required a JavaScript animation library because CSS easing was limited to cubic Bezier curves, which can overshoot at most once and can never oscillate. The linear() easing function changes that. It draws a polyline through as many points as you give it, so any curve — including a damped spring — can be approximated by sampling it. This page explains how linear() points work, how to generate a spring, how many points are enough, and how to keep the motion fast and accessible. It belongs to Easing & Motion Design in the CSS-Only Micro-Interactions & Animations guide.

Why a polyline beats a Bezier for springs

A cubic-bezier(x1, y1, x2, y2) curve has a fixed start at (0, 0), a fixed end at (1, 1), and two control points. Pull a control point's y above 1 and the curve overshoots — useful for a single "pop". But a spring does not overshoot once. It overshoots, swings back under the target, overshoots again by less, and settles, each swing smaller than the last. No single cubic curve can describe that shape.

linear(0, 0.25 10%, 0.9 40%, 1) means: output 0 at the start, 0.25 at 10% of the time, 0.9 at 40%, 1 at the end, with straight lines between. Output values may go above 1 or below 0, which is what allows overshoot and undershoot. With enough points, the straight segments become invisible and the polyline is indistinguishable from the smooth curve it samples.

One overshoot versus a damped oscillation Two plots of progress against time. The cubic Bezier rises, overshoots once above the target line and settles. The linear() spring overshoots, dips below the target, overshoots again by less, and settles, drawn as connected straight segments. What each easing type can express cubic-bezier(): one overshoot 1 linear(): damped spring 1

The anatomy of a linear() value

Each argument is an output value with an optional input position:

  • linear(0, 1) is plain linear timing — identical to the linear keyword.
  • linear(0, 0.5 25%, 1) reaches half its output at a quarter of the time: fast, then slow.
  • Points without a percentage are distributed evenly between their neighbours that have one, so linear(0, 0.3, 0.8, 1) places the middle two points at 33.3% and 66.7%.
  • A point may carry two percentages, 0.5 20% 40%, which holds the output at 0.5 for that interval — a pause built into the curve.

Outputs are not clamped. An animated translate from 0 to 100px with an output of 1.12 at some point overshoots to 112px. That is exactly what a spring needs, and it is also why springs must only be applied to properties where overshoot makes sense — position, scale, rotation — and not to properties with hard limits such as opacity, which clamps above 1 anyway.

The complete implementation

The toggle below uses a 27-point spring for the knob. Compare it with the ease-out row underneath to feel the difference; both use the same duration.

Live demoSpring easing from linear() versus a plain ease-out
Toggle both switches: the top knob overshoots and settles like a spring; the bottom one glides to a stop in the same 0.6s.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>linear() spring</title>
<style>
  :root {
    /* A moderately damped spring, sampled at 27 points (denser at the bends).
       Outputs above 1 are the overshoot; below 1 after the peak, the rebound. */
    --ease-spring: linear(
      0, 0.0086, 0.0335, 0.0722, 0.1224, 0.1817 9.8%, 0.3137 13.3%,
      0.4589 17.4%, 0.5991 21.7%, 0.7238 26%, 0.8277 30.4%, 0.9065 34.6%,
      0.9681 39.2%, 1.0082 43.8%, 1.0296 48.2%, 1.0356 52.4%, 1.0319 56.4%,
      1.0217 60.8%, 1.0097 65.3%, 1.0004 69.8%, 0.9951 74%, 0.9934 78.3%,
      0.9945 82.8%, 0.9973 87.5%, 0.9996 92.2%, 1.0004 96%, 1
    );
  }

  body { font: 15px/1.5 system-ui, sans-serif; margin: 1.5rem; display: grid; gap: 1rem; }

  .switch { display: inline-flex; align-items: center; gap: 0.75rem; cursor: pointer; }
  .switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }

  .switch__track {
    width: 3.5rem;
    height: 2rem;
    padding: 0.25rem;
    border-radius: 999px;
    background: #cbd5e1;
    transition: background-color 0.2s ease;
  }

  .switch__knob {
    display: block;
    width: 1.5rem;
    height: 1.5rem;
    border-radius: 50%;
    background: #ffffff;
    box-shadow: 0 1px 3px rgb(0 0 0 / 0.3);
    /* translate is a standalone property, so the spring moves the knob
       without interfering with any transform set elsewhere. */
    transition: translate 0.6s var(--ease-spring);
  }

  .switch input:checked + .switch__track { background: #4f46e5; }
  .switch input:checked + .switch__track .switch__knob { translate: 1.5rem 0; }
  .switch input:focus-visible + .switch__track { outline: 2px solid #1e1b4b; outline-offset: 2px; }

  /* Comparison: same duration, a conventional ease-out. */
  .switch--plain .switch__knob { transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }

  /* Reduced motion: no bounce, and a short, plain transition. */
  @media (prefers-reduced-motion: reduce) {
    .switch__knob { transition: translate 0.1s linear; }
  }
</style>
</head>
<body>
  <label class="switch"><input type="checkbox"><span class="switch__track"><span class="switch__knob"></span></span> Spring</label>
  <label class="switch switch--plain"><input type="checkbox"><span class="switch__track"><span class="switch__knob"></span></span> Ease-out</label>
</body>
</html>

Storing the curve in a custom property keeps the long point list out of every rule that uses it, and makes the spring a design token that components reference by name. The overshoot peaks at about 1.036 — the knob travels 3.6% past its target, less than a pixel at this size, but enough to register as a physical landing.

The key technique: sampling a spring into points

The points come from simulating a damped harmonic oscillator — a mass on a spring with friction — and recording its position at intervals. The equation of motion is standard physics; what matters for CSS is the procedure.

From spring parameters to a linear() value Choose stiffness, damping and mass; simulate position over time until it settles; sample the curve at points dense where it bends and sparse where it is straight; write the samples as a linear() argument list. A spring is a physics simulation, sampled once 1. Parameters stiffness damping mass 2. Simulate position over time until it settles 3. Sample dense at bends, sparse on the flat tail 4. Emit linear(0, 0.18 9.8%, …) The duration in CSS should equal the simulated settle time, or the element will look finished long before the animation ends.

In practice, generate the points with a small script at build time or with one of the several online linear() generators, and paste the result into a custom property. A few rules make the output better:

  1. Sample adaptively. Put more points where the curve bends sharply — the initial rise and the first overshoot — and fewer on the nearly flat tail. Adjacent points on a straight stretch can be merged without visible change.
  2. Trim the tail. Once the oscillation is below about half a percent of the travel, it is invisible. End the list there and set the CSS duration to that moment.
  3. Round to four decimal places. More precision adds bytes and no visible difference.
  4. Test at the real distance. A 3% overshoot is invisible on a 20-pixel knob and very visible on a 400-pixel drawer. Choose springs with less overshoot for larger movements.

Variation: a bounce, not a spring

A bounce is a different physical model — a ball hitting a floor — and it has a characteristic shape: the output reaches exactly 1 several times, rebounding by less each time, and never goes past 1. It suits drop-in notifications and "landing" effects better than a spring.

:root {
  /* Three bounces with decreasing height; output never exceeds 1. */
  --ease-bounce: linear(
    0, 0.063, 0.25, 0.563, 1 36.4%,
    0.812, 0.75, 0.813, 1 72.7%,
    0.953, 0.938, 0.953, 1 90.9%,
    0.984, 1 100%
  );
}

.toast {
  animation: drop-in 0.9s var(--ease-bounce) both;
}

@keyframes drop-in {
  from { translate: 0 -120%; }
  to   { translate: 0 0; }
}

@media (prefers-reduced-motion: reduce) {
  .toast { animation: none; }
}

The points at 1 36.4%, 1 72.7% and 1 90.9% are the floor contacts: exactly the target position, at the moments of impact. Between them the output dips back below 1 — upward in visual terms, since the element came from above. Because nothing goes past 1, the toast never passes through its resting position, which is what distinguishes a bounce from a spring. Bounces call for restraint: use them for rare, celebratory moments, never for routine UI such as menus, where they quickly become irritating. The broader guidance on how much character an easing should carry is in Cubic-Bezier Overshoot and Anticipation.

Browser support

The linear() easing function is supported in Chrome and Edge 113+, Firefox 112+ and Safari 17.2+. In an engine without it, a declaration using linear(...) is invalid and dropped, so declare a cubic Bezier fallback first — transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1) followed by the linear() version — or wrap the token in @supports (transition-timing-function: linear(0, 1)). The standalone translate property is supported in Chrome and Edge 104+, Firefox 72+ and Safari 14.1+.

FAQ

What does linear() do that cubic-bezier() cannot? A cubic Bezier has one curve with two control points, so it can overshoot once at most and cannot oscillate. linear() draws a polyline through any number of points, so it can approximate springs that overshoot and settle, bounces that hit the floor several times, and any other curve sampled finely enough.

How many points does a linear() spring need? Enough that the straight segments between them are not visible at the element's speed. Twenty to forty points covers most UI springs; very bouncy or long animations may need more. Because each point is only a number and optional percentage, the cost is stylesheet size, not runtime.

Does a spring easing change the animation's duration? No. linear() maps progress within the duration you set. A spring that visually settles at 60% of its curve will look finished early and then sit still, so choose the duration to match when the curve actually settles, or trim the tail of the point list.

Should spring easing be disabled for reduced motion? The overshoot and oscillation are extra movement, so under prefers-reduced-motion switch to a plain ease-out or remove the transition entirely. A spring is decoration on top of a state change that should remain instant and clear for those users.

Related articles

More pages in the same section.