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 })

テスト方法

完成した使い方サンプル

// パネルが下からふわっと出て、ホバーで弾む
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」のハイブリッドが現実解。 理屈を理解した上でライブラリを選ぶと、選定眼が育つ。