Skip to content
Fundamental · TimeBeginner

Easing & tweening

Turn time into a 0–1 progress, bend it with an easing curve, and build staged, staggered and back-and-forth animations from it.

How it works

A tween moves a value from one state to another over a set time: a wipe from left to right, a flash fading out, a panel sliding in. In a shader it is three steps. Turn time into a progress from 0 to 1, bend the progress with an easing curve, and mix between the start and end values with the result.

float p = clamp((time - startTime) / duration, 0.0, 1.0); // progress: 0 before, 1 after
float e = p * p * (3.0 - 2.0 * p);                         // eased (smoothstep's curve)
float x = mix(-0.2, 1.2, e);                               // the animated value

Keeping the three apart is the point: the easing curve can change without touching the timing, and the same progress can drive several values at once.

Easing curves

Motion at constant speed looks mechanical, because real objects speed up and slow down. Easing curves are shaping curves that keep 0 at 0 and 1 at 1, named by where they are gentle:

float easeInQuad(float p)    { return p * p; }                         // starts slow
float easeOutQuad(float p)   { return 1.0 - (1.0 - p) * (1.0 - p); }   // ends slow
float easeInOutQuad(float p) {                                         // slow at both ends
    return p < 0.5 ? 2.0 * p * p : 1.0 - 2.0 * (1.0 - p) * (1.0 - p);
}
float easeOutCubic(float p)  { float q = 1.0 - p; return 1.0 - q * q * q; }

Higher powers make the slow part longer and the fast part sharper. Ease-out suits things arriving on screen, since they settle into place; ease-in suits things leaving, since they gather speed; ease-in-out suits moves from one rest position to another.

Two families go past the ends on purpose. A “back” ease overshoots and returns, like a panel that springs slightly past its place:

float easeOutBack(float p) {
    const float k = 1.70158;          // the classic overshoot, about 10%
    float q = p - 1.0;
    return 1.0 + (k + 1.0) * q * q * q + k * q * q;
}

An elastic ease overshoots too, with a decaying sine on top. Bounce, a series of falling arcs, stays inside 0–1 but hits the end several times. Curves that overshoot produce values above 1 or below 0, so check that what they drive can take them: a color might need a clamp, while a position can simply pass its target.

CSS describes most easing as cubic-bezier() curves. A shader can’t evaluate those cheaply, because finding the value at a given time means solving a cubic equation, so shaders write the curve directly as a polynomial like the ones above.

Staged and staggered animation

Many animations have phases: a mask grows, then a color shifts, then everything fades. Give each phase its own window of the overall progress by remapping it:

float stage(float p, float a, float b) { return clamp((p - a) / (b - a), 0.0, 1.0); }

float grow  = easeOutCubic(stage(p, 0.0, 0.5)); // first half
float shift = stage(p, 0.3, 0.8);               // overlaps the end of the growth
float fade  = easeInQuad(stage(p, 0.7, 1.0));   // the last 30%

Windows can overlap, which usually reads as smoother than strictly one-after-the-other. Staggering is the same remap with a delay per element: for a row of tiles, stage(p, i * 0.05, i * 0.05 + 0.4) starts tile i a moment after the one before, the cascade used in transitions and UI reveals.

Back and forth, and loops

A tween that should repeat needs a progress that repeats. fract(time / period) restarts at 0 every period, which jumps back. For back and forth, fold it into a triangle that goes up and then down:

float loop     = fract(time / 2.0);           // 0..1 every 2 seconds
float pingPong = 1.0 - abs(loop * 2.0 - 1.0); // 0..1..0 every 2 seconds
float e        = easeInOutQuad(pingPong);     // eased both ways

To hold at each end before turning, remap the folded value: stage(pingPong, 0.1, 0.9) rests at 0 and at 1 for a moment, a fifth of each half, before moving on.

Further reading