キーフレーム
2 値だけのトゥイーンではなく、複数の節(key)を時系列に並べて滑らかにつなぐ仕組み。
CSS の @keyframes 相当を JS で自作する。
キーフレームとは
「0% でこの値、25% でこの値、100% でこの値」のように時間軸上の節を指定し、 その間を補間する手法。
時間 (0〜1) 値
0.0 ───── 100
0.25 ───── 300
0.6 ───── 150
1.0 ───── 500
0.25 0.5 0.6
┃ ┃ ┃
▆▅▄▃▂▁▂▃▄▅▆▇█
↑ ↑
100 500
API 設計
Web Animations API(WAAPI)に近い形が使いやすい:
animateKeyframes(target, [
{ x: 0, opacity: 0 }, // 0%
{ x: 200, opacity: 1 }, // 50%
{ x: 100, opacity: 0.5 }, // 100%
], { duration: 1500 })
// オフセット指定
animateKeyframes(target, [
{ offset: 0, x: 0 },
{ offset: 0.3, x: 200 }, // 30% で 200
{ offset: 1, x: 0 },
], { duration: 2000 })
最小実装
function animateKeyframes(target, frames, options = {}) {
const duration = options.duration ?? 1000
const easing = options.easing ?? ((t) => t)
// offset を正規化
const normalized = frames.map((f, i) => ({
...f,
offset: f.offset ?? i / (frames.length - 1),
}))
// どのプロパティを扱うか
const keys = new Set()
for (const f of normalized) {
for (const k in f) if (k !== "offset" && k !== "easing") keys.add(k)
}
return new Promise((resolve) => {
const start = performance.now()
function tick(now) {
const t = Math.min((now - start) / duration, 1)
const e = easing(t)
for (const key of keys) {
// 該当する区間を探す
for (let i = 0; i < normalized.length - 1; i++) {
const a = normalized[i]
const b = normalized[i + 1]
if (e >= a.offset && e <= b.offset) {
const localT = (e - a.offset) / (b.offset - a.offset)
const segEase = b.easing ?? ((t) => t)
const finalT = segEase(localT)
target[key] = a[key] + (b[key] - a[key]) * finalT
break
}
}
}
if (t < 1) requestAnimationFrame(tick)
else resolve()
}
requestAnimationFrame(tick)
})
}
区間ごとに違うイージング
各キーフレームに easing を持たせて、その区間の入り方を変えられる:
animateKeyframes(target, [
{ x: 0 },
{ x: 200, easing: Easings.easeOutCubic }, // 0→200 の区間に適用
{ x: 100, easing: Easings.easeInOutCubic },
{ x: 500, easing: Easings.easeOutBack },
], { duration: 2000 })
プロパティごとに独立した keyframes
WAAPI の「prop ベースの配列」形式(GSAP の keyframes も同様):
animatePropKeyframes(target, {
x: [0, 200, 100, 500],
opacity: [0, 1, 0.5],
}, { duration: 2000 })
// 内部実装
function animatePropKeyframes(target, propMap, options) {
// 各 prop で長さが違うことがある → それぞれ補間
return new Promise((resolve) => {
const start = performance.now()
const duration = options.duration ?? 1000
function tick(now) {
const t = Math.min((now - start) / duration, 1)
for (const key in propMap) {
const arr = propMap[key]
const seg = (arr.length - 1) * t
const i = Math.min(Math.floor(seg), arr.length - 2)
const localT = seg - i
target[key] = arr[i] + (arr[i + 1] - arr[i]) * localT
}
if (t < 1) requestAnimationFrame(tick)
else resolve()
}
requestAnimationFrame(tick)
})
}
WAAPI に直接渡す(自作不要パターン)
単に DOM をアニメさせるならWeb Animations API が完成度高い:
box.animate([
{ transform: "translate(0, 0)", opacity: 0, offset: 0 },
{ transform: "translate(200px, 0)", opacity: 1, offset: 0.5 },
{ transform: "translate(100px, 50px)", opacity: 0.5, offset: 1 },
], {
duration: 1500,
easing: "ease-out",
fill: "forwards",
iterations: 1,
})
ブラウザがネイティブで補間するためパフォーマンスが最高。 ただし JS 側で値を読みたい / Canvas / WebGL に流すなら自作の方が良い。
スプライン補間(カトマルロム)
単純な「区間ごとの線形補間」だと、節をまたぐ瞬間に角が立つ。 滑らかにしたいならカトマルロムスプライン。
function catmullRom(p0, p1, p2, p3, t) {
const t2 = t * t, t3 = t2 * t
return 0.5 * (
(2 * p1) +
(-p0 + p2) * t +
(2*p0 - 5*p1 + 4*p2 - p3) * t2 +
(-p0 + 3*p1 - 3*p2 + p3) * t3
)
}
function smoothKeyframes(values, t) {
const seg = (values.length - 1) * t
const i = Math.floor(seg)
const localT = seg - i
const p0 = values[Math.max(i - 1, 0)]
const p1 = values[i]
const p2 = values[Math.min(i + 1, values.length - 1)]
const p3 = values[Math.min(i + 2, values.length - 1)]
return catmullRom(p0, p1, p2, p3, localT)
}
2D / 3D の経路補間
座標を持つ点の配列をスプラインでつなぐ:
function smoothPath(points, t) {
return {
x: smoothKeyframes(points.map(p => p.x), t),
y: smoothKeyframes(points.map(p => p.y), t),
}
}
const path = [
{ x: 0, y: 0 },
{ x: 100, y: 50 },
{ x: 50, y: 200 },
{ x: 300, y: 150 },
]
function tick(now) {
const t = ((now / 3000) % 1)
const p = smoothPath(path, t)
box.style.transform = `translate(${p.x}px, ${p.y}px)`
requestAnimationFrame(tick)
}
SVG パスに沿わせる
SVG パスは数学的に正確な経路。標準 API で点を取得できる:
const path = document.querySelector("path#guide")
const total = path.getTotalLength()
function tick(now) {
const t = ((now / 4000) % 1)
const pt = path.getPointAtLength(t * total)
// 接線方向で向きも合わせる
const lookahead = path.getPointAtLength(t * total + 1)
const angle = Math.atan2(lookahead.y - pt.y, lookahead.x - pt.x)
el.style.transform = `translate(${pt.x}px, ${pt.y}px) rotate(${angle}rad)`
requestAnimationFrame(tick)
}
ベジエ手書きの経路
SVG が無くてもベジエ曲線を直接実装:
function bezier3(p0, p1, p2, p3, t) {
const u = 1 - t
return {
x: u**3*p0.x + 3*u**2*t*p1.x + 3*u*t*t*p2.x + t**3*p3.x,
y: u**3*p0.y + 3*u**2*t*p1.y + 3*u*t*t*p2.y + t**3*p3.y,
}
}
const start = { x: 0, y: 0 }
const ctrl1 = { x: 100, y: -100 }
const ctrl2 = { x: 200, y: 100 }
const end = { x: 300, y: 0 }
const pos = bezier3(start, ctrl1, ctrl2, end, t)
イージング付きキーフレームの実用例
ローディングのドット 3 つ
document.querySelectorAll(".dot").forEach((dot, i) => {
setInterval(() => {
animateKeyframes(dot.style, [
{ opacity: 0.3 },
{ opacity: 1, easing: Easings.easeOutCubic },
{ opacity: 0.3, easing: Easings.easeInCubic },
], { duration: 900 })
}, 1200)
// delay でずらしても OK
})
ハートビート
animatePropKeyframes(state, {
scale: [1, 1.15, 1, 1.1, 1],
}, {
duration: 1000,
// 心拍 ↑↓↑↓ を表現
})
カードフリップ
animatePropKeyframes(state, {
rotateY: [0, 90, 90, 180],
scale: [1, 1.1, 1.1, 1],
}, { duration: 800 })
キーフレーム + 物理(ハイブリッド)
着地のような物理的な動きを再現したい場合、最後だけスプリング、それ以前はキーフレームというハイブリッドも有効:
// 1) 70% までキーフレームで派手に
await animateKeyframes(...)
// 2) 残りはスプリングで揺れる(→ spring.html)
spring(state, { y: 0 }, { stiffness: 150 })
キーフレームのデバッグ
- 各キーで一旦止めてプロパティ確認 (
seek()機能) - プロパティを1 つだけ動かして挙動を確認
- イージングを
linearにして補間が正しいか - 境界(offset 0 / 1)の値が期待通りか
パフォーマンスメモ
- キーフレーム数が増えても 1 フレーム内の区間検索は O(N)。N が小さければ無視できる
- 巨大な N(1000 以上)なら二分探索
- 毎フレーム配列を作り直さない(参照を保持)
WAAPI vs 自作の早見表
| 条件 | 選択 |
|---|---|
| DOM 要素のみ | WAAPI |
| Canvas / WebGL の値 | 自作 |
| フレームごとに値を取りたい | 自作(onUpdate) |
| ブラウザで最速 | WAAPI(コンポジタ層で動く) |
| イベント連動 / 動的に値変更 | 自作 or motion |
ループ系はそもそも CSS の @keyframes で書ける場合が多い。JS で fadeIn / spin / bounce を作る前に、
CSS で済まないか考える。複雑な制御 / シーケンス / Canvas のときに JS の出番。