Nobody writes
cubic-bezier(0.34, 1.56, 0.64, 1)from scratch. You drag handles on a visual editor like cubic-bezier.com and copy the result. I rebuilt that kind of editor in 500 lines of vanilla JS: pink (P1) and green (P2) handles you can drag, an SVG curve that updates live, a preview box that animates with the easing, and the matching CSS string. The interesting part wasn't drawing the curve — it was the math browsers do every animation frame: cubic-bezier has no closed-form inverse for what CSS needs, so the browser binary-searches it.
🌐 Demo: https://sen.ltd/portfolio/bezier-editor/
📦 GitHub: https://github.com/sen-ltd/bezier-editor
The math
A CSS cubic-bezier(x1, y1, x2, y2) curve has four control points:
- P0 = (0, 0) — fixed start
- P1 = (x1, y1) — first user handle
- P2 = (x2, y2) — second user handle
- P3 = (1, 1) — fixed end
Standard parametric cubic Bézier:
B(t) = (1−t)³ P0 + 3(1−t)² t P1 + 3(1−t) t² P2 + t³ P3
Split into x and y components:
export function bezierX(t, x1, x2) {
const u = 1 - t;
return 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t;
}
export function bezierY(t, y1, y2) {
const u = 1 - t;
return 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t;
}
Clean. Now the problem.
CSS asks the wrong question
What CSS needs each frame:
- Normalised elapsed time x ∈ 0, 1
- Output progress y for that x
But the parametric form is "give me t, get (x, y)" — not "give me x, get y." You can't directly evaluate bezierY at x = 0.5 because y depends on t, and t isn't x.
The equation bezierX(t) = x is a cubic in t:
t³ + 3(x2 − x1) t² + 3 x1 t − x = 0
(after rearranging). Cubics have a closed-form solution (Cardano), but numerically it's a pain across x1, x2 ∈ [0, 1] with possibly-degenerate roots. Browsers don't bother. They binary-search:
export function solveT(x, x1, x2, iters = 20) {
if (x <= 0) return 0;
if (x >= 1) return 1;
let lo = 0, hi = 1;
for (let i = 0; i < iters; i++) {
const t = (lo + hi) / 2;
const fx = bezierX(t, x1, x2);
if (fx < x) lo = t;
else hi = t;
}
return (lo + hi) / 2;
}
20 iterations gives 2⁻²⁰ ≈ 10⁻⁶ precision — sub-pixel on any practical canvas. Chromium and WebKit add a Newton step for faster initial convergence, but a plain bisection is fine for a teaching tool.
The headline function ends up trivial:
export function easeAt(x, x1, y1, x2, y2) {
const t = solveT(x, x1, x2);
return bezierY(t, y1, y2);
}
That's the function the browser invokes per frame.
The asymmetric constraint that makes back-out work
CSS Easing Functions Level 1, section 3.3:
The x-coordinates must be in the range [0, 1] or the function is invalid. The y-coordinates may have any value.
Clamp x. Don't clamp y. That's the spec wiggle that makes back-out / spring overshoot legal:
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
y1 = 1.56 means the curve overshoots its target by 56% before settling. Material Design's spring family, iOS's UI animations, all live there.
The editor encodes this asymmetry:
export function normalize(x1, y1, x2, y2) {
return [
Math.max(0, Math.min(1, x1)), // x: clamped
y1, // y: free
Math.max(0, Math.min(1, x2)),
y2,
];
}
And the SVG canvas reserves vertical space above y = 1 and below y = 0 so the overshoot region is visible. Otherwise users drag a handle off-screen and wonder why the curve doesn't reach where they put it.
SVG rendering
The curve itself is one polyline over 80 sample points:
export function samples(x1, y1, x2, y2, n = 80) {
const pts = [];
for (let i = 0; i <= n; i++) {
const t = i / n;
pts.push({ x: bezierX(t, x1, x2), y: bezierY(t, y1, y2) });
}
return pts;
}
The handle lines (P0→P1, P3→P2) are dashed lines. The draggable points are SVG <circle> elements with pointerdown/pointermove handlers, plus setPointerCapture so dragging continues when the pointer leaves the SVG bounds. Without setPointerCapture the drag drops the moment your cursor exits — annoying for a slow careful drag near a corner.
Coordinate transforms are just linear:
function xToPx(x) { return x * SVG_W; }
function yToPx(y) { return SVG_H - y * SVG_H; }
SVG y goes down, the curve's y goes up — hence the flip.
CSS keyword reverse lookup
The named CSS easings are specific cubic-beziers:
export const NAMED = {
"ease": [0.25, 0.1, 0.25, 1.0],
"ease-in": [0.42, 0, 1, 1],
"ease-out": [0, 0, 0.58, 1],
"ease-in-out": [0.42, 0, 0.58, 1],
"linear": [0, 0, 1, 1],
};
When the current quad equals one of these (within 0.01 tolerance), the tool surfaces both forms:
ease /* cubic-bezier(0.25, 0.1, 0.25, 1) */
Reverse-lookup makes "what's ease?" answerable by experimentation — drag the handles, watch when the label flips.
The bug my tests caught
Initial draft test:
test("linear (0,0,1,1) → identity in both axes", () => {
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
assert.ok(Math.abs(bezierX(t, 0, 1) - t) < 1e-9);
}
});
Failed. cubic-bezier(0, 0, 1, 1) is the CSS spec value for linear, but the parametric form bezierX(t, 0, 1) evaluates to:
3(1−t)t² · 1 + t³ = t²(3 − 2t) // a smoothstep, not a line in t
At t = 0.25: 0.0625 · 2.5 = 0.156, not 0.25.
The resolution: bezierY(t, 0, 1) is the same expression, so bezierY = bezierX and easeAt(x) = x becomes an identity. The output IS linear in x — the parameter t just isn't linear in x.
Fixed tests:
test("solveT(x) inverts bezierX: bezierX(solveT(x), …) ≈ x", () => {
for (const x of [0.1, 0.3, 0.5, 0.7, 0.9]) {
const t = solveT(x, 0, 1);
assert.ok(Math.abs(bezierX(t, 0, 1) - x) < 1e-4);
}
});
test("linear is identity (as an easing function)", () => {
for (const x of [0, 0.25, 0.5, 0.75, 1]) {
assert.ok(Math.abs(easeAt(x, 0, 0, 1, 1) - x) < 1e-4);
}
});
Writing the wrong assumption down made me re-derive the math properly. Tests as a forcing function for understanding.
12 presets
export const PRESETS = [
{ label: "ease", quad: [0.25, 0.1, 0.25, 1.0] },
{ label: "ease-in-back", quad: [0.6, -0.28, 0.735, 0.045] },
{ label: "ease-out-back", quad: [0.34, 1.56, 0.64, 1] }, // Material spring
{ label: "ease-in-out-back",quad: [0.68, -0.55, 0.27, 1.55] },
{ label: "spring", quad: [0.5, 1.8, 0.5, 1] },
// ... 12 total
];
Click "ease-out-back," watch the preview box overshoot its target and snap back. The relationship between y > 1 in the curve and "overshoot in motion" becomes visceral immediately.
Architecture
bezier.js ← bezierX, bezierY, solveT, easeAt, samples, normalize, toCSS, matchNamed (DOM-free, 18 tests)
presets.js ← 12 preset quads
app.js ← SVG rendering + pointer handlers + animation glue
bezier.js is pure. 18 Node tests cover the parametric form, the binary-search inverse, the (x → y) easing function, sample generation, x-clamp + y-free normalisation, CSS string formatting, named keyword matching with tolerance. The UI layer is SVG draws and pointer events — small enough to read end to end.
Try it
Drag the pink handle straight up off the visible area. Watch the curve overshoot. That's why y isn't clamped.
Takeaways
- cubic-bezier is parametric in t, but CSS needs (x → y). The browser inverts via binary search every animation frame.
- No closed-form inverse is required. 20 iterations of bisection gets you below pixel precision; Chromium / WebKit add Newton for speed but the simple form works fine.
- The asymmetric constraint — x clamped, y free — is what makes back-out and spring easings possible.
-
linearascubic-bezier(0, 0, 1, 1)is a smoothstep in t, but identity in the (x → y) mapping. Easy to misread, easy to test for. -
setPointerCaptureis essential for slow drags near canvas edges. Without it, your handle drops mid-motion.
This is OSS portfolio #253 from SEN LLC (Tokyo). Pairs naturally with #244 css-animation-designer — keyframes there, easing curves here. https://sen.ltd/portfolio/

Top comments (0)