Skip to content
Michael Malura
570 GitHub stars 5810 npm downloads 53 Projects

Easing and tweening

Moving a square from A to B is quick to build: start plus difference times progress. It looks like a calculator. A menu that slides in at constant speed and stops dead feels wooden, with a curve in front of it it comes alive. These curves are called easing functions, and a tween is the piece of code that walks along them over time. The time comes from delta time. If you run your simulation with a fixed timestep, pass that step in instead of the frame time.

The demo starts with a gallery of eleven families: linear, sine, quad, cubic, quart, quint, expo, circ, back, elastic and bounce. Every card draws its curve in teal, the orange dot is the current time on it, and the square below runs back and forth between two points with the same value. The chips at the bottom switch all cards between in, out and inOut. Clicking a card opens the detail view on the right with a plot, an object with position, scale and rotation, chips for duration and loop or once, and a trail of ghost images. The ghosts sit 40 ms apart, so where they are far from each other the object is fast. Tab takes you to the tween chain.

Bending time

The path stays a straight line. Time is what gets bent. An easing function takes the linear progress t between 0 and 1 and returns how far along the path the object is. f(0) = 0 and f(1) = 1 hold for all of them, in between the value may leave the range 0 to 1. For back and elastic that is the whole point, the object shoots past the target and comes back.

template <class T>
T mix(const T& a, const T& b, double k) { return a + (b - a) * k; }

// in the tween:
return mix(from, to, ease(progress));

T is anything with plus, minus and times double: the demo tweens a double, a Vec2 and a color.

The formulas

Quad is t^2, cubic t^3, quart t^4, quint t^5. The higher the power, the longer the curve stays flat at the start. Sine is 1 - cos(t * pi / 2), circ is a quarter circle, 1 - sqrt(1 - t^2). Expo is 2^(10t - 10) and gives only 0.000977 at t = 0 instead of 0, which is why it has special cases at both ends.

Back is c3 * t^3 - c1 * t^2 with c1 = 1.70158 and c3 = c1 + 1. The c3 is there so that t = 1 gives 1 again. The 1.70158 comes from Robert Penner's easing equations, where it is the default of the parameter s. With it back overshoots by about 10 percent, and the largest value of backOut works out to 1.1000.

Elastic is a sine with a decaying envelope: -2^(10t - 10) * sin((10t - 10.75) * c4) with c4 = 2pi / 3. c4 sets one oscillation to 0.3 of the duration, which is Penner's default as well. Bounce is four parabolas, the constant 7.5625 is 2.75 squared, so the first parabola hits exactly 1 at t = 1/2.75. The tops of the bounces are at 0.75, 0.9375 and 0.984375, each bounce is a quarter as high as the one before.

in, out, inOut

Out is the mirror image of in: out(t) = 1 - in(1 - t). That holds for every family, springs included. InOut runs the first half as in(2t) / 2 and the second half mirrored:

inline double polyOut(double t, int n) { return 1.0 - ipow(1.0 - t, n); }
inline double polyInOut(double t, int n) {
    return t < 0.5 ? ipow(2.0, n - 1) * ipow(t, n) : 1.0 - ipow(-2.0 * t + 2.0, n) / 2.0;
}

Back and elastic use their own constants for inOut (c2 = c1 * 1.525, c5 = 2pi / 4.5), so there it is not exactly in composed with itself. It is still symmetric around the point (0.5, 0.5), and the test checks inOut(1 - t) = 1 - inOut(t) for all 33 functions.

The tween

A tween holds start, target, duration, function and a clock. update(dt) advances the clock by the frame time, value() computes the value from it. Because the clock collects seconds, the result does not depend on the frame rate. The test runs the same tween with 1000 steps of 1 ms, with 60 steps of 1/60 s and with one step of a second, and all three end up at the same value. The chips "20 fps" and "5 fps" in the tween view show it: it stutters, but the chain ends at the same time.

If a tween ends in the middle of a frame, part of the dt is left over. That rest belongs to the next tween, otherwise every link starts a frame late, up to 200 ms at 5 fps. So update returns the part it did not use:

elapsed_ += dt;
if (elapsed_ >= duration) {
    const double rest = elapsed_ - duration;
    elapsed_ = duration;
    done_ = true;
    return rest;
}
return 0.0;

updateChain passes the rest from tween to tween. The demo chains move (0.8 s, backOut), grow (0.6 s, elasticOut) and tint (0.6 s, quadInOut), each of a different type:

auto step = [&](auto& tween) {
    if (blocked || tween.finished()) return;
    rest = tween.update(rest);
    if (!tween.finished()) blocked = true;
};
(step(tweens), ...);

The blocked is needed. Without that line a tween with duration 0 would fire as soon as its predecessor is still running, because it gets an update(0). I took the line out as a test, and the test fails.

What it is for and what not

Menus that slide in, the camera shifting focus, a pickup that pops when you collect it, an enemy that swells when hit. All of them have a fixed duration, start and target.

It is not for physics. Easing knows neither mass nor forces, bounce looks like a bouncing ball but only plays back a curve and never hits anything. A moving target is a poor fit too: start a new tween from the current value every frame and each one begins at speed 0 with an ease-in, so the camera just crawls.

Pitfalls

The functions expect t between 0 and 1, which is why progress() clamps. With duration 0 there would be a division by zero, so progress() returns 1 and update passes on all of the time. I tween colors as doubles from 0 to 255 and only clamp when drawing, otherwise an overshoot overflows a byte. The middle between teal and orange is a rather brownish something, you can see it in the chain.

The code is in the repo under easing. The project Game Dev Snippets collects all demos of the series.

30.10.2026 updated 06.11.2026
Share X Reddit Hacker News LinkedIn E-Mail
Subscribe to new posts RSS JSON