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.

One spring, three ways to approximate itThe sampled stop list traces the real oscillation; the bezier can only overshoot once and never returns below the line.One spring, three ways to approximate it1.000.750.500.250.000.000.250.500.751.00elapsed time (fraction of duration)progresslinear() sampled from a damped springcubic-bezier(0.34, 1.56, 0.64, 1)
The sampled stop list traces the real oscillation; the bezier can only overshoot once and never returns below the line.

Step-by-step: sampling a curve into stops

From a physical description to a stop listThe whole process happens once, at author time, and produces a value you can paste into a token file.From a physical description to a stop list1Decide the behaviour in words: how many bounces, how far past the target,how long to settle.Three bounces, 14% overshoot, settled by 70%2Evaluate the damped-spring formula at evenly spaced fractions of theduration.Twelve to twenty progress values3Round each value to three decimals and drop any that sit on a straightline between neighbours.A shorter list with an identical shape4Write the stops into a custom property so components reference the token,not the numbers.5Add the cubic-bezier fallback above it and the reduced-motion overridebelow it.
The whole process happens once, at author time, and produces a value you can paste into a token file.

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.

Choosing between the three timing function familiesReach for linear() when the shape needs more than one direction change.Choosing between the three timing function familiescubic-bezier()linear()steps()Simple ease inor outBestOverkillWrong shapeSingleovershootBestWorksWrong shapeMulti-bouncesettleImpossibleBestWrong shapePause partwaythroughImpossibleRepeat a stop valueDiscrete onlyFrame-by-framespriteWrong shapeWrong shapeBestCarries runtimevelocityNoNoNo
Reach for linear() when the shape needs more than one direction change.

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 scale compounds visually with overshoot on translate — 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.