Building Spring-Like Easing Functions: linear() Instead of Spring Libraries
AI generated
{ }
@
CSS · Easing · linear() · Spring Animation
Building Spring-Like Easing Functions
linear() Instead of Spring Libraries

Real spring physics in animations used to be the domain of JavaScript libraries like Framer Motion or React Spring. The CSS function linear() now allows arbitrarily complex easing curves, including overshoot and bounce, to be defined directly as a list of control points in the stylesheet, with zero runtime JavaScript.

16 min read linear() · custom properties · overshoot · damping Chrome 113+ · Firefox 112+ · Safari 17.2+

1. Why CSS never had real springs before

Classic CSS timing functions like ease, ease-in-out or even cubic-bezier() exclusively describe monotonic curves between two values: the speed changes, but the direction never does. A real spring motion, in contrast, overshoots its target and only settles after several oscillations, a behavior a single Bézier polynomial cannot mathematically represent. This is exactly why developers have almost always reached for JavaScript libraries for spring-like easing effects, libraries that recompute the physics of a damped spring on every single frame.

These JavaScript solutions work reliably, but they cost something pure CSS does not: main thread time on every animation frame, extra bundle weight from the library itself, and a dependency on the execution of the JavaScript code, which can stutter on slow devices or a blocked main thread. A spring-like easing function that lives entirely in CSS avoids all of these problems, because the browser can play the animation on the compositor thread.

With the CSS function linear(), exactly that is now possible. Instead of a smooth mathematical formula, linear() describes a sequence of control points between which the browser interpolates linearly. With enough control points, any curve can be approximated, including one that shoots past 100 percent and swings back, exactly the behavior of a physical spring.

2. Understanding linear(): control points instead of a curve

The syntax of linear() is deliberately simple: a comma-separated list of numbers, optionally with a percentage position on the time axis. linear(0, 0.5 50%, 1), for example, defines three control points: at 0 percent time progress sits at 0, at 50 percent time at 0.5, at 100 percent time at 1. Between control points the browser interpolates linearly, which looks angular with few points but is practically indistinguishable from a smooth curve with many points.

The key difference to cubic-bezier(): values in linear() do not have to stay between 0 and 1. A value of 1.1 means the animation reaches 110 percent of its target value at that point, before swinging back. This ability to briefly exceed the target value and return is the foundation of every spring-like easing curve in pure CSS.


/* Basic linear() syntax: comma-separated stops, optional position */
.simple-ease {
  transition-timing-function: linear(0, 0.25, 0.75, 1);
}

/* Explicit position: value at a specific percentage of the duration */
.overshoot-preview {
  transition-timing-function: linear(
    0,
    0.6 25%,
    1.08 55%,   /* overshoots past the target value */
    0.98 75%,
    1
  );
}

A simple overshoot feel needs only five to seven control points. For a convincing, multi-oscillation spring animation with a visible settle, however, twenty to forty control points are typical, which realistically nobody types by hand anymore but instead generates from a physical formula.

3. Generating a spring function mathematically

The physics of a damped spring can be described with three parameters: stiffness, damping, and the mass of the moving object. The standard formula for the position of a damped harmonic oscillation at time t is, simplified, 1 - e^(-ζωt) · cos(ωd·t), where ζ describes the damping ratio and ωd the damped angular frequency. This formula is evaluated at many discrete points in time, and each value becomes a control point in the linear() list.

In practice, a small script computes a fixed number of control points for a desired combination of stiffness and damping and outputs a ready-made linear() declaration. This script does not need to run in production code, it is pure build-time tooling: the result is a static CSS line that produces exactly the same visual curve as a JavaScript spring library at runtime, but without any runtime cost.


// Build-time script: generate a linear() easing string from spring physics
function springToLinear(stiffness = 300, damping = 20, mass = 1, steps = 30) {
  const w0 = Math.sqrt(stiffness / mass);
  const zeta = damping / (2 * Math.sqrt(stiffness * mass));
  const wd = w0 * Math.sqrt(1 - zeta * zeta);

  const points = [];
  for (let i = 0; i <= steps; i++) {
    const t = i / steps;
    const decay = Math.exp(-zeta * w0 * t * 4); // scaled duration window
    const value = 1 - decay * Math.cos(wd * t * 4);
    points.push(value.toFixed(4));
  }
  return `linear(${points.join(', ')})`;
}

console.log(springToLinear(300, 20, 1, 24));
// linear(0, 0.1834, 0.4412, ..., 1.0812, ..., 0.9987, 1)

This generated string is copied into the stylesheet once and remains static afterward. If the desired spring character changes, for example less damping for a more playful overshoot, the script is run once more and the new string replaces the old one. This is the same workflow used for generated sprite sheets or compressed assets: generation at build time, static result at runtime.

4. Recreating overshoot and bounce with linear()

Not every spring-like easing curve has to come from a full physical simulation. For many UI purposes, a hand-built approximation with a few characteristic control points is enough to create the impression of a spring, without claiming the mathematical rigor of an exact simulation. A typical pattern: fast rise, slight overshoot past the target value, brief spring-back, then settle.

For a bounce effect, where an element hits a surface repeatedly like a ball, several consecutive overshoots with decreasing amplitude work well. These patterns can be cataloged as reusable recipes: a gentle spring for tooltips, an energetic bounce for success messages, an almost imperceptible overshoot for hover states.


/* Gentle spring: subtle overshoot, good for tooltips and popovers */
:root {
  --ease-spring-soft: linear(0, 0.52 30%, 0.98 55%, 1.02 70%, 1);
}

/* Energetic bounce: multiple decaying overshoots, good for success states */
:root {
  --ease-spring-bounce: linear(
    0, 0.35 15%, 0.72 30%, 1.06 45%,
    0.92 58%, 1.03 68%, 0.98 78%, 1.01 88%, 1
  );
}

.toast-enter {
  animation: toast-in 0.5s var(--ease-spring-bounce) both;
}
@keyframes toast-in {
  from { transform: translateY(-40px) scale(0.9); opacity: 0; }
}

The amplitude of the overshoot matters for believable spring animations: more than about eight to twelve percent past the target quickly looks exaggerated and distracts from the actual content. For micro-interactions like button clicks or small status changes, two to five percent overshoot is usually the more convincing choice, for more prominent elements like modal dialogs or success messages a bit more can work.

5. Tools for generating linear() values

Since nobody wants to compute forty control points by hand, several web based generators have established themselves that simulate a spring from stiffness, damping and mass and output the finished linear() string with one click, often with a live preview of the resulting motion right there. These tools take on exactly the role of the build script shown in the previous section, just with a graphical interface instead of code.

For teams that need consistent spring animations across many components, it is still worth having a small in-repo Node script that uses the exact same formula as the web tools, but is integrated into the project's own design token pipeline. This keeps spring parameters versioned and traceable, instead of hidden inside an external web app whose result only ever lands in the project as copy-pasted text.

6. Making spring easings reusable as custom properties

A generated linear() string is long and unwieldy if it has to be repeated in every single rule. The clean approach is to declare each finished spring-like easing curve once as a CSS custom property on :root and reference it everywhere in the stylesheet via var(), exactly like a color value or a spacing token.

This approach turns spring easings into a genuine design token: --ease-spring-soft, --ease-spring-bounce, --ease-spring-snappy and similar can be maintained centrally, documented, and swapped project-wide when needed, without touching every single component. When the design language changes, only the value of the custom property changes.


:root {
  /* Named spring tokens — generated once, reused everywhere */
  --ease-spring-snappy: linear(0, 0.62 22%, 1.05 42%, 0.99 60%, 1);
  --ease-spring-soft: linear(0, 0.52 30%, 0.98 55%, 1.02 70%, 1);
}

.button {
  transition: transform 0.3s var(--ease-spring-snappy);
}
.button:active {
  transform: scale(0.96);
}

.modal-panel {
  animation: modal-in 0.4s var(--ease-spring-soft) both;
}

7. Performance comparison: linear() versus JavaScript spring libraries

The performance advantage of a linear() based spring animation over a JavaScript spring library lies mainly in the execution environment. A CSS animation with linear() timing, as long as only transform and opacity are animated, is computed entirely on the compositor thread. The main thread stays free for other work, and even when it is blocked by heavy JavaScript, the animation keeps running visibly smoothly.

A JavaScript spring library, in contrast, typically recomputes the spring physics on the main thread inside every requestAnimationFrame callback and writes the result into inline styles. If the main thread is busy with other work, for example parsing a large JSON response, the animation stutters. The same advantage applies to bundle size: a linear() curve costs zero extra JavaScript weight, while common spring libraries add several kilobytes to the gzipped bundle size.

8. Limits of linear() and when JavaScript remains necessary

As powerful as linear() is for static, precomputed curves, the technique reaches its limits for interactive, physics-based animations with constantly changing target values. A drag interaction, where the user drags an element and a spring motion to the nearest snap point should kick in on release, only knows the exact target value at runtime. A precomputed linear() curve cannot account for this dynamic target, because it represents a fixed sequence of control points for a fixed distance.

For such cases with a variable target distance at runtime, a JavaScript spring library remains the right choice, because it recomputes the physics every frame with the current target value. A sensible compromise in many projects: use linear() for all predictable UI transitions like modals, toasts and hover states, and only reach for JavaScript springs where real user interaction actually determines the target distance in real time.

9. Easing functions compared

Choosing between the available easing techniques benefits from a direct comparison of the key properties, from the classic Bézier curve to a fully physically generated spring.

Technique Overshoot possible Runtime cost Dynamic target distance
cubic-bezier() No None Not relevant
linear() spring Yes None (compositor) No
JavaScript spring library Yes Main thread per frame Yes
steps() No None Not relevant

For the vast majority of UI transitions with a known, fixed distance, linear() is the superior choice: zero bundle weight, no main thread cost, the same visual quality as a spring library. Only for real-time interactive drag gestures with a variable target distance does JavaScript remain indispensable.

Mironsoft

Fine-grained micro-interactions and animation design systems

Animations that feel alive instead of mechanical?

We build custom linear() based spring easings for your design system, including build tooling for generation and documentation as reusable custom properties.

Analysis

Reviewing existing animations for consistency and overshoot behavior

Implementation

Generating a spring token set and wiring it up as custom properties

Performance

Compositor thread check and comparison against existing JS libraries

10. Summary

The CSS function linear() makes genuine spring-like easing curves possible without any JavaScript library. Unlike cubic-bezier(), it allows values beyond 100 percent and therefore an overshoot past the target value, the core feature of every spring motion. Curves with few control points are enough for a light overshoot feel, physically generated curves with twenty to forty control points deliver multi-oscillation, convincing spring animations.

The generated linear() values are best maintained as named custom properties, so spring easings become genuine design tokens instead of being duplicated in every rule. The performance advantage over JavaScript spring libraries is significant: no main thread cost, no extra bundle size, the same visual quality. Only with a dynamic target distance at runtime, such as drag interactions, does a JavaScript solution remain the right choice.

Building Spring-Like Easing Functions — Key Takeaways

linear() foundation

Values above 1 create overshoot, something cubic-bezier() cannot do.

Generation

Compute spring physics once at build time, copy the static linear() string into the CSS.

Design tokens

Maintain spring curves as custom properties on :root, centrally swappable.

Limits

With a dynamic target distance at runtime, a JavaScript spring library remains necessary.

11. FAQ: Building Spring-Like Easing Functions

1How is linear() different from cubic-bezier()?
A control point list instead of a formula, with values above 1 enabling overshoot, which cubic-bezier() cannot represent.
2How many control points are needed?
Five to seven for a light overshoot, twenty to forty for a multi-oscillation spring.
3How do I generate the control points?
With a build script that evaluates the damped oscillation formula and outputs it as a linear() list.
4Does linear() cost performance?
No, with transform and opacity everything runs on the compositor thread, independent of the main thread.
5How much overshoot is appropriate?
Two to five percent for micro-interactions, up to twelve percent for more prominent elements.
6Store it as a custom property?
Yes, this turns spring easings into a reusable design token without duplication.
7Does linear() replace JavaScript springs?
For fixed distances yes, for dynamic target distance at runtime JavaScript remains necessary.
8Browser support?
Chrome and Edge from 113, Firefox from 112, Safari from 17.2. A cubic-bezier() fallback is recommended.
9Bounce with multiple impacts?
Define several consecutive overshoots with decreasing amplitude as control points.
10Where to store multiple variants?
As named custom properties on :root, documented in the project's design system.