Using the linear() Easing Function for Custom Curves
Part of Timing Functions & Easing Curves in Core CSS Animation Fundamentals.
The problem
cubic-bezier() has exactly two control handles, which buys exactly one direction change. That is enough for every ease-in, ease-out and single overshoot — and not enough for a spring that settles through two or three diminishing bounces, a staged reveal that pauses partway, or any curve traced from a physical simulation.
The traditional answer was to run the simulation in JavaScript on every frame. linear() replaces it with a sampled stop list: the curve is evaluated once, at author time, and the browser interpolates between the samples. The motion stays declarative, stays on the compositor, and costs nothing per frame beyond a table lookup.
Root cause: an easing function maps time to progress
Every timing function answers one question — given a fraction of the duration, what fraction of the way through the animation are we? cubic-bezier answers it with a parametric curve constrained to two handles. steps() answers it with a staircase. linear() answers it with an explicit list of points, joined by straight segments.
That last form is strictly more expressive. Any curve can be approximated to arbitrary precision by enough points, and because progress values are unbounded, the list can rise above 1 and fall back — which is what an overshoot is. Multiple overshoots are simply more excursions above 1.
The trade-off is that the shape is fixed at author time. A linear() curve cannot respond to a runtime velocity or a moving target; if the motion has to carry momentum from an interrupted gesture, only a live solver driving the Web Animations API will do. For the overwhelming majority of interface motion — where the same bounce should look the same every time — a sampled curve is both sufficient and cheaper.
Step-by-step: sampling a curve into stops
The percentages in a linear() list are optional. Without them the stops are distributed evenly, which is what you want when the samples were taken at even intervals. With them — linear(0, 0.6 30%, 1) — you can bunch samples where the curve is changing fastest and save stops where it is nearly straight.
Production code pattern
/* Intent: one spring curve as a token, applied to a card entrance, with a
fallback for engines that do not support linear() and a reduced-motion
branch that removes the bounce entirely. */
:root {
/* Sampled from a damped spring: 14% overshoot, settling by ~70%. */
--ease-spring: linear(
0, 0.28, 0.62, 0.9, 1.08, 1.14, 1.12, 1.04, 0.975, 0.955, 0.972, 0.995, 1.005, 1
);
--ease-spring-fallback: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.card {
translate: 0 12px;
opacity: 0;
/* Older engines stop at the fallback; newer ones take the linear() curve. */
transition:
translate 420ms var(--ease-spring-fallback),
opacity 260ms linear;
transition-timing-function: var(--ease-spring), linear;
}
.card.is-revealed { translate: 0 0; opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.card {
transition: opacity 140ms linear;
translate: 0 0; /* settle immediately, no bounce, no travel */
}
}
Rendering Impact:
translate+opacity— composite only. The stop list is parsed once during style; each frame is a lookup and a linear interpolation between two neighbouring stops, which is cheaper than solving a cubic.
Note the fallback mechanism. A browser that does not understand linear() drops the whole transition-timing-function declaration and keeps the shorthand’s bezier — a standard CSS fallback, no feature query needed. Keeping the fallback shape close to the sampled one matters more than matching it exactly: the point is that the older engine still gets deliberate motion rather than the default ease.
Verification checklist
Constraints and trade-offs
- The shape is fixed at author time; an interrupted gesture cannot carry its velocity into a sampled curve.
- Too few stops produce visible corners, especially at durations over roughly 500 ms.
- Overshooting curves move the element past its final value, so anything clipped by an ancestor needs room or the bounce is cut off.
- A long stop list is bytes in every stylesheet that uses it; tokenise it once rather than inlining it per component.
- Overshoot on
scalecompounds visually with overshoot ontranslate— use one or the other in a single effect.
Frequently asked questions
How many stops does a linear() curve need?
Twelve to twenty for a spring or bounce, fewer for a simple staged curve. The browser interpolates linearly between stops, so the question is whether the segments are short enough that the corners are invisible at the duration you are using — a 200 ms animation hides more than an 800 ms one.
Is linear() slower than cubic-bezier?
No. Both are evaluated per frame wherever the animation runs, and a stop-list lookup is cheaper than solving a cubic. The practical difference is stylesheet size, not runtime cost.
Can linear() express an overshoot?
Yes — that is one of its main uses. Stop values above 1 overshoot the end state and values below 0 undershoot, and unlike cubic-bezier you can have as many of each as the motion needs.
Trimming a stop list without changing the shape
A sampler that emits a value every 2% produces fifty stops, most of which sit on a straight line between their neighbours and contribute nothing. Trimming them is worth doing once, because the token is shipped in every stylesheet that references it.
The rule is simple: a stop can be removed if the linear interpolation between its neighbours passes within a small tolerance of it. Walking the list once and dropping every such point typically takes a fifty-stop spring down to twelve or fourteen with no visible difference — the curvature is concentrated around the overshoot and the first settle, and those are the points the pass keeps.
Two practical notes. Keep the first and last stops regardless, since they define the endpoints. And set the tolerance in progress units rather than as a percentage of the value: 0.005 is generous for interface motion, where a half-percent error in progress is well below what anyone can perceive at a few hundred milliseconds.
If the trimmed curve does show a visible corner, it will be at the sharpest turn — usually the peak of the first overshoot. Adding one stop back on either side of that peak fixes it for far fewer bytes than lowering the tolerance across the whole list.
Related
- Timing Functions & Easing Curves — the parent guide covering the whole timing-function family
- Approximating Spring Physics with CSS Easing — when a sampled curve is enough and when a solver is required
- How to Calculate Cubic-Bezier for Natural Motion — choosing the fallback shape deliberately