Every CSS animation you tune ends in a cubic-bezier(x1, y1, x2, y2) — and most people treat those four numbers as magic. They are not. They are two control points on a curve, and I built a from-scratch editor to make that obvious. It has exactly one hard idea and a lot of easy plumbing, and the hard idea is the same math a browser runs internally.
An easing is a function from time to progress
The single biggest misconception is that the curve is a path across the screen. It is not. The horizontal axis is time (0 → 1) and the vertical axis is how far the animation has progressed (0 → 1). Where the curve is steep, motion is fast; where it is flat, the element barely moves. The curve always starts at (0,0) and ends at (1,1), so the endpoints never move — you only edit the two control points, which is why the function takes four numbers, not eight.
let curve = { x1: 0.25, y1: 0.1, x2: 0.25, y2: 1.0 }; // this is "ease"
// x1, x2 clamped to 0..1 (time can't reverse); y1, y2 are FREE (can overshoot)
Each axis is a cubic polynomial
With the endpoints fixed at 0 and 1, the Bézier for one axis collapses to the tidy a·t³ + b·t² + c·t — the classic WebKit "UnitBezier" form. Pre-computing three coefficients makes evaluating the curve, and later its slope, a couple of multiplications.
function coeffs(a1, a2){
const c = 3 * a1;
const b = 3 * (a2 - a1) - c;
const a = 1 - c - b; // forces the axis to reach 1 at t = 1
return { a, b, c };
}
function bezier(t, a1, a2){ // a t³ + b t² + c t (Horner form)
const { a, b, c } = coeffs(a1, a2);
return ((a * t + b) * t + c) * t;
}
The one genuine trap: t is not x
Here is the part everyone gets wrong. A Bézier is traced by a hidden parameter t that produces both an x and a y. But an animation asks "at time x = 0.5, how far along am I?" — and the t that gives x = 0.5 is generally not 0.5, because the x-axis is itself a curved function of t. So you cannot plug your time straight into the formula. You must first solve x(t) = time for t, and only then read y(t). Skipping this and using time directly as t is the classic bug that makes home-rolled easings subtly wrong.
Solving with Newton-Raphson (and a bisection net)
To invert x(t) = time I use Newton-Raphson, which converges in a handful of steps because t ≈ x is already a great starting guess. Each step corrects by the error divided by the slope (the derivative 3a·t² + 2b·t + c). When the curve is nearly vertical the slope collapses and Newton can diverge, so there is a bisection fallback that always brackets the root. This is exactly what browser engines do.
function solveT(x, x1, x2){
if (x <= 0) return 0;
if (x >= 1) return 1;
let t = x; // x is a great first guess
for (let i = 0; i < 8; i++){ // Newton-Raphson
const err = bezier(t, x1, x2) - x;
if (Math.abs(err) < 1e-6) return t;
const d = bezierD(t, x1, x2);
if (Math.abs(d) < 1e-6) break; // flat slope → bisect instead
t -= err / d;
}
let lo = 0, hi = 1; t = x; // bisection always works
for (let i = 0; i < 40; i++){
const xv = bezier(t, x1, x2);
if (Math.abs(xv - x) < 1e-6) break;
if (xv < x) lo = t; else hi = t;
t = (lo + hi) / 2;
}
return t;
}
With the solver in hand, the easing itself is one line: find t for the given time on the x-axis, then read the y-axis there.
function ease(x, x1, y1, x2, y2){
return bezier(solveT(x, x1, x2), y1, y2); // time in → eased progress out
}
Why x is clamped but y is free
Time cannot run backwards, so x1 and x2 are forced into 0–1. Progress has no such rule — and that asymmetry is the whole source of springy motion. A y-value above 1 means the animation races past its target and settles back (a "back" or bounce ease, like cubic-bezier(0.34, 1.56, 0.64, 1)); a y below 0 means it first backs away, winding up like a crouch before a jump (anticipation). In the demo you can drag a handle above the top line and watch the ball overshoot.
Everything else is plumbing
The named keywords are just presets — ease is literally cubic-bezier(.25,.1,.25,1), linear is the straight diagonal. Dragging the two dots clamps x to 0–1 and leaves y free. One requestAnimationFrame clock computes a single eased value and feeds it to a ball's position, a scale, an opacity, and a rotation at once — so they stay perfectly in sync, and you can watch opacity clamp while scale happily overshoots.
The lesson underneath: an easing is not a picture, it is a function from time to progress — and once you can solve the curve, everything visual is just multiplying a range by one number.
Drag the two control points, hit a preset, and copy the exact cubic-bezier(…):
https://dev48v.infy.uk/solve/day36-cubic-bezier-editor.html
Top comments (0)