DEV Community

SEN LLC
SEN LLC

Posted on

Building a Visual cubic-bezier Editor in 500 Lines — Why Browsers Binary-Search the Inverse

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

Screenshot

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Clean. Now the problem.

CSS asks the wrong question

What CSS needs each frame:

  1. Normalised elapsed time x ∈ 0, 1
  2. 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
Enter fullscreen mode Exit fullscreen mode

(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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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,
  ];
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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],
};
Enter fullscreen mode Exit fullscreen mode

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) */
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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
];
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
  • linear as cubic-bezier(0, 0, 1, 1) is a smoothstep in t, but identity in the (x → y) mapping. Easy to misread, easy to test for.
  • setPointerCapture is 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)