transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1)を手で書ける人はほぼいない。普通は cubic-bezier.com などのビジュアルエディタで操作してコピペしてくる。今回はその種類のエディタを 500 行 vanilla JS で実装した。ピンク (P1) と緑 (P2) のハンドルをドラッグすると、SVG の曲線が更新され、隣のボックスが即その easing で動き、CSS が表示される。実装する中で 「ブラウザはどうやって easing を計算しているのか」 — cubic-bezier に閉形式の逆関数がない問題と二分探索による解 がそのまま教材になった。
🌐 Demo: https://sen.ltd/portfolio/bezier-editor/
📦 GitHub: https://github.com/sen-ltd/bezier-editor
cubic-bezier の数学
CSS の cubic-bezier(x1, y1, x2, y2) は 4 つの制御点 で定義される曲線:
- P0 = (0, 0) ← 固定 (アニメ開始)
- P1 = (x1, y1) ← ユーザ操作
- P2 = (x2, y2) ← ユーザ操作
- P3 = (1, 1) ← 固定 (アニメ終了)
パラメトリックなフォームは:
B(t) = (1-t)³ P0 + 3(1-t)² t P1 + 3(1-t) t² P2 + t³ P3
x 軸と y 軸の式を分離して書くと:
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;
}
シンプル。だが 問題はここから:
CSS の仕様: 「時間 x 経ったとき、進捗 y はどれくらい?」
アニメーションが動くときブラウザがやっていることは:
- 現在の経過時間を正規化 → x ∈ [0, 1] (0 = 開始、1 = 終了)
- その x に対する y を計算: それが「進捗 %」になる
ところがパラメトリック表現は t を入れると (x, y) ペアが出る 形。x を入れて y を求めるには、「bezierX(t) = x になる t」 を逆引きする必要がある。
三次方程式に閉形式の逆関数がない
bezierX(t) = x は t の三次方程式:
t³ + 3(x2 - x1) t² + 3 x1 t - x = 0
…の形になる (整理は割愛)。三次方程式には Cardano の閉形式解が一応あるが、CSS で扱う x1, x2 ∈ [0,1] の範囲で数値的に安定 に解くのが大変。ブラウザは閉形式を使わず 二分探索 (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 イテレーションで 2^-20 ≈ 10^-6 精度。CSS easing の使用範囲では十分。Chromium / WebKit の実装は Newton 法と二分探索のハイブリッドで初期収束を加速しているが、一行で書ける近似 としては二分探索で良い。
「時間 x → t → 進捗 y」 を 1 つにまとめると:
export function easeAt(x, x1, y1, x2, y2) {
const t = solveT(x, x1, x2);
return bezierY(t, y1, y2);
}
これがブラウザが各アニメーションフレームで内部的に計算している関数。
オーバーシュート: y の範囲制約がない
CSS Easing Functions Level 1 仕様には:
The x-coordinates must be in the range [0, 1] or the function is invalid. The y-coordinates may have any value.
x は [0, 1] にクランプ、y はクランプしない。これが back-out / spring の overshoot 表現を可能にしている:
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
y1 = 1.56 は「目標位置を 56% 通り過ぎてから戻る」動き。Material Design や iOS の spring がこの族。
実装側でもこの非対称性を反映する:
export function normalize(x1, y1, x2, y2) {
return [
Math.max(0, Math.min(1, x1)), // x はクランプ
y1, // y はそのまま
Math.max(0, Math.min(1, x2)),
y2,
];
}
UI 側ではドラッグハンドルが y 方向に canvas の上下に飛び出せる ように描画領域を広めに取る (今回の実装は y ∈ [-0.33, 1.33] くらいを表示)。
SVG レンダリング
曲線本体は polyline 1 つ:
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;
}
80 サンプルで <polyline points="...">。ハンドル線 (P0-P1, P3-P2) は dashed line で。ドラッグ可能な点は SVG <circle> に pointerdown / pointermove で実装、setPointerCapture を使うとマウスが SVG 外に出ても drag が継続する。
座標変換は単純な線形マップ:
function xToPx(x) { return x * SVG_W; }
function yToPx(y) { return SVG_H - y * SVG_H; }
function pxToX(px) { return px / SVG_W; }
function pxToY(py) { return 1 - py / SVG_H; }
SVG の y 軸は下向きなので符号反転が必要。
CSS keyword のフィードバック
ease, ease-in, ease-out, ease-in-out, linear は 特定の cubic-bezier 値の別名:
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],
};
current quad がこのどれかに一致したら、ユーザに キーワード形式も表示 する:
ease /* cubic-bezier(0.25, 0.1, 0.25, 1) */
「ease ってどんな曲線?」を逆方向に学べるツールになる。一致判定はトレラン 0.01 の浮動小数点比較で。
バグだったテスト: 「linear の cubic-bezier(0,0,1,1) は bezierX(t) = t ではない」
実装中に踏んだ罠がこれ。cubic-bezier(0, 0, 1, 1) は CSS keyword の linear の定義値 (MDN にもそう書いてある)。だが パラメトリック表現としては y = x の直線ではない:
bezierX(t, 0, 1) = 3(1-t)t² · 1 + t³ = t²(3 - 2t) // smoothstep
つまり bezierX(0.25, 0, 1) = 0.0625 × 2.5 = 0.156... で 0.25 ではない。
ただし bezierY も同じ式で bezierX = bezierY。easeAt(x) の出力としては y = x の identity になる (x → t → y で同じ式を 2 回通すので戻る)。
テスト書き直し:
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", () => {
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);
}
});
「中間のパラメータ t が線形」≠「最終出力 y が x に対して線形」 を test で記録すると、後で誰かが「linear の bezierX(0.5) は 0.5 だろう?」と勘違いするのを防げる。
12 種類のプリセット
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 種
];
back 系がオーバーシュートを生むので、ボタンを 1 クリックして preview が「目標を通り過ぎてから戻る」のを見ると、y > 1 の意味が体感できる。
アーキテクチャ
bezier.js ← bezierX, bezierY, solveT, easeAt, samples, normalize, toCSS, matchNamed (DOM 非依存、18 tests)
presets.js ← 12 種のプリセット quad
app.js ← SVG 描画 + pointer event + animation glue
bezier.js には document も window も登場しない。Node テストで bezier 数学を完全に検証してから、UI 層は SVG 描画とイベント処理だけを担当する。
まとめ
- cubic-bezier は 「時間 x → 進捗 y」 のマッピング、内部は
bezierX(t) = xの 二分探索による逆引き - 三次方程式に閉形式の逆関数はあるが、数値的に安定 な二分探索 (20 iter で十分) が実装の定番
- x は [0, 1] にクランプ、y はクランプしない のが overshoot easing の根拠
-
linearキーワード =cubic-bezier(0, 0, 1, 1)はパラメトリックには smoothstep だが、easeAt(x) = xの identity を保つ - SVG 描画は
<polyline>1 つ、ハンドルは<circle>+ pointer events、setPointerCaptureで外への drag を拾う
リポジトリ: https://github.com/sen-ltd/bezier-editor
このツールは弊社の OSS ポートフォリオ #253 として作成しました。CSS animation 系では #244 css-animation-designer と組合せで使うと「キーフレームを書く + イージング曲線を作る」の両方が完結します。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/
