Tween クラスを自作する
最小実装から始めて、Promise / 制御 / オブジェクトの直接操作 / イベント / yoyo / リピートまで 積み上げて、GSAP 風の使い勝手を作る。
API 設計の指針
まずは「使う側のコード」から決める。これが書き味になる。
// 単発、Promise で待てる
await tween(box, { x: 200, opacity: 1 }, { duration: 800, easing: easeOutCubic })
// 制御可能
const t = tween(box, { x: 200 }, { duration: 800 })
t.pause()
t.resume()
t.seek(0.5)
t.reverse()
// イベント
t.on("update", (v) => ...)
t.on("complete", () => ...)
// チェイン
t.then(() => tween(box, { x: 0 }, { duration: 400 }))
ステップ 1: 関数版
最小限。1 値 → 1 値、Promise を返す:
function tween({ from, to, duration, easing = (t) => t, onUpdate }) {
return new Promise((resolve) => {
const start = performance.now()
function frame(now) {
const t = Math.min((now - start) / duration, 1)
const v = from + (to - from) * easing(t)
onUpdate(v)
if (t < 1) requestAnimationFrame(frame)
else resolve()
}
requestAnimationFrame(frame)
})
}
ステップ 2: オブジェクト操作
ターゲットを直接書き換える形にする:
function tween(target, props, { duration = 500, easing = (t) => t } = {}) {
return new Promise((resolve) => {
const from = {}
for (const key in props) from[key] = target[key]
const startTime = performance.now()
function frame(now) {
const t = Math.min((now - startTime) / duration, 1)
const e = easing(t)
for (const key in props) {
target[key] = from[key] + (props[key] - from[key]) * e
}
if (t < 1) requestAnimationFrame(frame)
else resolve()
}
requestAnimationFrame(frame)
})
}
// 使い方
const obj = { x: 0, y: 0 }
await tween(obj, { x: 100, y: 50 }, { duration: 1000, easing: Easings.easeOutCubic })
console.log(obj.x, obj.y) // 100, 50
ステップ 3: DOM の transform を当てる
DOM 要素は文字列ベースの transform なので、内部の数値オブジェクトを更新しつつ毎フレーム文字列を組み立てる。
function tweenDOM(el, props, options = {}) {
const state = {}
const initial = parseTransform(el.style.transform)
for (const key in props) state[key] = initial[key] ?? 0
const onUpdate = (s) => {
el.style.transform = `translate(${s.x}px, ${s.y}px) scale(${s.scale ?? 1}) rotate(${s.rotate ?? 0}deg)`
if (s.opacity !== undefined) el.style.opacity = s.opacity
}
return tween(state, props, { ...options, onUpdate })
}
function parseTransform(str) {
// 簡易: 既存値の取得
const out = { x: 0, y: 0, scale: 1, rotate: 0 }
// ... 実装は割愛
return out
}
ステップ 4: クラスにして制御を持つ
pause / resume / seek / reverse などを加える。累積経過時間を内部に持つ。
class Tween {
constructor(target, props, options = {}) {
this.target = target
this.props = props
this.duration = options.duration ?? 500
this.easing = options.easing ?? ((t) => t)
this.onUpdate = options.onUpdate
this.onComplete = options.onComplete
this.delay = options.delay ?? 0
this.repeat = options.repeat ?? 0 // -1 で無限
this.yoyo = options.yoyo ?? false
this.from = {}
for (const key in props) this.from[key] = target[key]
this.elapsed = 0
this.iteration = 0
this.paused = false
this.reversed = false
this.done = false
this._lastTime = null
this._promise = new Promise((r) => this._resolve = r)
this._raf()
}
_raf = (now) => {
if (this.done) return
if (this._lastTime === null) this._lastTime = now ?? performance.now()
const cur = now ?? performance.now()
const dt = cur - this._lastTime
this._lastTime = cur
if (!this.paused) {
this.elapsed += this.reversed ? -dt : dt
this._tick()
}
requestAnimationFrame(this._raf)
}
_tick() {
if (this.elapsed < this.delay) return
const localElapsed = this.elapsed - this.delay
let t = Math.max(0, Math.min(localElapsed / this.duration, 1))
// yoyo: 偶数イテレーションは正、奇数は逆
let effectiveT = t
if (this.yoyo && this.iteration % 2 === 1) {
effectiveT = 1 - t
}
const eased = this.easing(effectiveT)
for (const key in this.props) {
this.target[key] = this.from[key] + (this.props[key] - this.from[key]) * eased
}
this.onUpdate?.(this.target, t)
if (t >= 1) {
// ループ判定
if (this.repeat > this.iteration || this.repeat === -1) {
this.iteration++
this.elapsed = this.delay // リセット
} else {
this.done = true
this.onComplete?.()
this._resolve()
}
}
}
pause() { this.paused = true; return this }
resume() { this.paused = false; this._lastTime = performance.now(); return this }
reverse() { this.reversed = !this.reversed; return this }
seek(t) {
this.elapsed = this.delay + this.duration * t
this._tick()
return this
}
stop() { this.done = true; this._resolve() }
then(...args) { return this._promise.then(...args) }
}
// 使い方
const t = new Tween(obj, { x: 100 }, { duration: 1000, easing: Easings.easeOutCubic })
await t
ステップ 5: 単一の rAF でまとめる
各 Tween が独自に rAF を起動するとオーバーヘッドが大きい。シングルトンマネージャで一括管理:
class TweenManager {
static instances = new Set()
static running = false
static lastTime = null
static add(tween) {
this.instances.add(tween)
this.start()
}
static remove(tween) {
this.instances.delete(tween)
}
static start() {
if (this.running) return
this.running = true
this.lastTime = performance.now()
requestAnimationFrame(this.loop)
}
static loop = (now) => {
const dt = now - this.lastTime
this.lastTime = now
for (const t of this.instances) t.update(dt)
if (this.instances.size > 0) {
requestAnimationFrame(this.loop)
} else {
this.running = false
}
}
}
class Tween {
constructor(target, props, options) {
// ... 上と同じ
TweenManager.add(this)
}
update(dt) {
if (this.done || this.paused) return
this.elapsed += this.reversed ? -dt : dt
this._tick()
if (this.done) TweenManager.remove(this)
}
}
ステップ 6: ヘルパー API
from / to / fromTo
// 現状から目標へ
Tween.to(obj, { x: 100 }, { duration: 500 })
// 開始値を強制(現状を無視)
Tween.from(obj, { x: -100 }, { duration: 500 }) // -100 → 現在値
// 両方指定
Tween.fromTo(obj, { x: -100 }, { x: 100 }, { duration: 500 })
実装例
Tween.to = (target, props, options) => new Tween(target, props, options)
Tween.from = (target, props, options) => {
const orig = {}
for (const key in props) orig[key] = target[key]
Object.assign(target, props)
return new Tween(target, orig, options)
}
Tween.fromTo = (target, fromProps, toProps, options) => {
Object.assign(target, fromProps)
return new Tween(target, toProps, options)
}
ステップ 7: per-property のイージングと duration
GSAP の { x: { value: 100, ease: "elastic" } } 風:
function normalizeProp(prop, fallback) {
if (typeof prop === "number") return { value: prop, ...fallback }
return { ...fallback, ...prop }
}
class Tween {
constructor(target, props, options) {
this.target = target
this.tracks = {}
for (const key in props) {
const p = normalizeProp(props[key], options)
this.tracks[key] = {
from: target[key],
to: p.value,
duration: p.duration ?? options.duration,
easing: p.easing ?? options.easing,
delay: p.delay ?? 0,
}
}
// ...
}
_tick(elapsed) {
for (const key in this.tracks) {
const tr = this.tracks[key]
const local = Math.max(0, Math.min((elapsed - tr.delay) / tr.duration, 1))
const eased = tr.easing(local)
this.target[key] = tr.from + (tr.to - tr.from) * eased
}
}
}
ステップ 8: stagger
複数要素に対して少しずつズラして同じアニメーションを適用。
function stagger(targets, props, options = {}) {
const each = options.each ?? 50 // ms
return targets.map((t, i) =>
new Tween(t, props, { ...options, delay: (options.delay ?? 0) + i * each })
)
}
// 使用
stagger(document.querySelectorAll(".item"), { opacity: 1, y: 0 }, {
duration: 600,
easing: Easings.easeOutCubic,
each: 80,
})
ステップ 9: イベント
EventEmitter を仕込む:
class Tween {
constructor() {
this._listeners = {}
}
on(event, fn) {
(this._listeners[event] ??= []).push(fn)
return this
}
emit(event, ...args) {
this._listeners[event]?.forEach((fn) => fn(...args))
}
// _tick の中で:
// this.emit("update", target, t)
// this.emit("complete")
}
ステップ 10: チェイン(直列化)
// then があれば async/await でつなげる
const a = new Tween(obj, { x: 100 }, { duration: 500 })
await a
const b = new Tween(obj, { y: 50 }, { duration: 500 })
await b
ただしタイムラインを使った方が宣言的。詳細は タイムライン。
差分パターン: モーションキャンセル
同じターゲットへ新しい Tween を投げたら、古い Tween は自動キャンセルしたい:
const activeTweens = new WeakMap() // target → Set<Tween>
function tween(target, props, options) {
const set = activeTweens.get(target) ?? new Set()
// 新しい Tween の対象 prop と被るやつを止める
for (const t of set) {
for (const key in props) {
if (key in t.tracks) t.stop()
}
}
const t = new Tween(target, props, options)
set.add(t)
activeTweens.set(target, set)
t.on("complete", () => set.delete(t))
return t
}
差分パターン: relative 値
現在値 + 値で動かしたい:
// "+=100" 形式
function resolveProp(target, key, raw) {
if (typeof raw === "string") {
if (raw.startsWith("+=")) return target[key] + parseFloat(raw.slice(2))
if (raw.startsWith("-=")) return target[key] - parseFloat(raw.slice(2))
}
return raw
}
tween(box, { x: "+=100" }, { duration: 500 })
差分パターン: 単位付き値
"100px" や "50%" を扱う:
function parseValue(raw) {
if (typeof raw === "number") return { value: raw, unit: "" }
const m = String(raw).match(/^(-?\d+\.?\d*)(.*)$/)
return { value: parseFloat(m[1]), unit: m[2] }
}
// onUpdate で `${value}${unit}` で書き戻す
差分パターン: 関数値(per-target カスタム)
// 値が関数なら、ターゲットごとに評価
tween(targets, {
x: (target, i) => i * 50,
}, { duration: 500 })
テスト方法
- 固定 dt で
update(dt)を呼んで結果を検証 - パフォーマンス: 1000 個並列で 60fps を維持できるか
- 境界:
duration: 0、repeat: -1
完成した使い方サンプル
// パネルが下からふわっと出て、ホバーで弾む
const panel = document.querySelector(".panel")
// 入場
Tween.from(panel, { y: 50, opacity: 0 }, {
duration: 600,
easing: Easings.easeOutCubic,
})
// ホバー
panel.addEventListener("mouseenter", () => {
Tween.to(panel, { scale: 1.05 }, {
duration: 300,
easing: Easings.easeOutBack,
})
})
panel.addEventListener("mouseleave", () => {
Tween.to(panel, { scale: 1 }, { duration: 300 })
})
// クリックで派手に
panel.addEventListener("click", async () => {
await Tween.to(panel, { rotate: 360 }, {
duration: 600,
easing: Easings.easeInOutCubic,
})
panel.style.transform = "rotate(0deg)" // 状態リセット
})
自作で全部揃えるのは大変。「自作のミニ tween」+「複雑な演出は GSAP」のハイブリッドが現実解。 理屈を理解した上でライブラリを選ぶと、選定眼が育つ。