タイムライン

複数のトゥイーンを時系列に並べて、まとめて再生・一時停止・スクラブする仕組み。 GSAP Timeline / motion animate 風の実装を作る。

なぜタイムラインが要るか

Tween 単体では「A → B」しか書けない。実際の演出は「A 動かして 0.5 秒待って B 動かして同時に C」のように複雑。 Promise チェインだと書きづらい:

// 読みづらい
await tween(a, { x: 100 })
await Promise.all([
  tween(b, { y: 50 }),
  tween(c, { opacity: 1 }),
])
await wait(300)
await tween(d, { rotate: 360 })

宣言的に書きたい:

const tl = new Timeline()
tl.add(a, { x: 100 }, { duration: 500 })
  .add(b, { y: 50 }, { duration: 500 }, "+=0")  // 同時
  .add(c, { opacity: 1 }, { duration: 500 }, "<")  // 直前と同時
  .add(d, { rotate: 360 }, { duration: 800 }, "+=300")
tl.play()

用語

最小実装

class Timeline {
  constructor() {
    this.tracks = []  // { startAt, duration, target, props, easing, ... }
    this.duration = 0  // タイムライン全体の長さ(自動計算)
    this.elapsed = 0
    this.paused = true
  }

  add(target, props, options = {}, position) {
    const startAt = this._resolve(position)
    const dur = options.duration ?? 500
    this.tracks.push({
      startAt,
      duration: dur,
      target,
      props,
      from: null,  // 開始時に確定
      easing: options.easing ?? ((t) => t),
      onComplete: options.onComplete,
      done: false,
    })
    this.duration = Math.max(this.duration, startAt + dur)
    return this
  }

  _resolve(pos) {
    if (typeof pos === "number") return pos
    if (pos === undefined || pos === ">") return this.duration
    if (pos === "<") {
      const last = this.tracks[this.tracks.length - 1]
      return last ? last.startAt : 0
    }
    if (typeof pos === "string") {
      const m = pos.match(/^([+-])=(\d+\.?\d*)$/)
      if (m) {
        const sign = m[1] === "-" ? -1 : 1
        return this.duration + sign * parseFloat(m[2])
      }
    }
    return this.duration
  }

  play() {
    this.paused = false
    this._lastTime = performance.now()
    requestAnimationFrame(this._tick)
    return this
  }
  pause() { this.paused = true; return this }

  _tick = (now) => {
    if (this.paused) return
    const dt = now - (this._lastTime ?? now)
    this._lastTime = now
    this.elapsed += dt
    this._render()
    if (this.elapsed < this.duration) {
      requestAnimationFrame(this._tick)
    } else {
      this._render()
      this.paused = true
      this.onComplete?.()
    }
  }

  _render() {
    for (const tr of this.tracks) {
      const local = this.elapsed - tr.startAt
      if (local < 0) continue            // まだ開始していない
      if (local > tr.duration && tr.done) continue  // 終了済み

      // from を確定(lazy)
      if (tr.from === null) {
        tr.from = {}
        for (const key in tr.props) tr.from[key] = tr.target[key]
      }

      const t = Math.min(local / tr.duration, 1)
      const eased = tr.easing(t)
      for (const key in tr.props) {
        tr.target[key] = tr.from[key] + (tr.props[key] - tr.from[key]) * eased
      }

      if (t === 1 && !tr.done) {
        tr.done = true
        tr.onComplete?.()
      }
    }
  }

  seek(time) {
    this.elapsed = Math.max(0, Math.min(time, this.duration))
    // from を全部リセットして再評価する必要がある場合あり
    this._render()
    return this
  }
}

使用例

const tl = new Timeline()

tl.add(box, { x: 200 }, { duration: 600, easing: Easings.easeOutCubic })
  .add(box, { y: 100 }, { duration: 400 }, "+=200")          // x 終了+200ms 後
  .add(label, { opacity: 1 }, { duration: 300 }, "<")        // 直前と同時
  .add(box, { rotate: 360 }, { duration: 800 }, ">")         // 直前の終了直後
  .add(box, { scale: 1.5 }, { duration: 300 }, "-=200")      // 終了 200ms 前から

tl.play()

ラベル(名前付き位置)

class Timeline {
  // ...
  labels = {}

  addLabel(name, position) {
    this.labels[name] = this._resolve(position)
    return this
  }

  _resolve(pos) {
    // 既存の解決に加えて
    if (typeof pos === "string" && this.labels[pos] !== undefined) {
      return this.labels[pos]
    }
    // ラベル + 相対値
    const m = pos?.match(/^([\w-]+)([+-]=\d+\.?\d*)?$/)
    if (m && this.labels[m[1]] !== undefined) {
      let base = this.labels[m[1]]
      if (m[2]) base += this._resolve(m[2])
      return base
    }
    // ...既存ロジック
  }
}

// 使用
tl.addLabel("intro", 0)
  .add(a, {...}, {}, "intro")
  .addLabel("middle", 1000)
  .add(b, {...}, {}, "middle+=200")
  .seekToLabel("middle")

ネスト(タイムラインの入れ子)

タイムラインを1 つの Tween のように扱えると再利用できる:

function makeIntroTl() {
  const tl = new Timeline()
  tl.add(title, { y: 0, opacity: 1 }, { duration: 500 })
    .add(subtitle, { opacity: 1 }, { duration: 300 }, "<0.2")
  return tl
}

const main = new Timeline()
main.add(makeIntroTl(), {}, { duration: 1000 })          // ネスト
    .add(box, { x: 100 }, { duration: 500 }, ">")

実装するには「Tween か Timeline か」を区別して、再帰的に _render を呼ぶ。

再生制御

tl.play()       // 再生
tl.pause()      // 一時停止
tl.resume()     // 再開
tl.reverse()    // 逆再生
tl.restart()    // 0 に戻して再生
tl.seek(500)    // 任意の位置にジャンプ
tl.progress(0.5) // 0〜1 で位置指定(時間を計算)
tl.timeScale(2) // 2 倍速

timeScale の実装

class Timeline {
  scale = 1
  timeScale(s) { this.scale = s; return this }

  _tick = (now) => {
    const dt = (now - this._lastTime) * this.scale
    this.elapsed += dt
    // ...
  }
}

逆再生(reverse)

逆再生は scale = -1 でも実装可。elapsed を負方向に進めて、各トラックを巻き戻す。

reverse() {
  this.scale = -Math.abs(this.scale)
  // ...
  // _render は elapsed = 0〜duration のすべてに対応している必要
}

スクラブ可能な UI

タイムラインの強みは「任意の位置にジャンプできる」こと。シークバー UI を作る:

<input type="range" min="0" max="1000" id="scrub">
const tl = new Timeline()
// ...いろいろ追加して duration が確定

scrub.max = tl.duration
scrub.addEventListener("input", (e) => {
  tl.pause()
  tl.seek(parseFloat(e.target.value))
})

onUpdate / onStart / onComplete

各トラックや全体にコールバックを仕込む。GSAP に揃える場合の API:

tl.add(box, { x: 100 }, {
  duration: 500,
  onStart:    () => console.log("box 開始"),
  onUpdate:   (target, t) => console.log("進行", t),
  onComplete: () => console.log("box 完了"),
})

// タイムライン全体
tl.on("complete", () => console.log("全部完了"))

並列ヘルパー("<" を内蔵)

パターン的によく使うので糖衣構文を:

tl.parallel((p) => {
  p.add(a, { x: 100 })
  p.add(b, { x: 200 })
  p.add(c, { y: 50 })
})

// 内部で同じ startAt を共有
parallel(builderFn) {
  const baseAt = this.duration
  const proxy = {
    add: (target, props, options) => {
      this.tracks.push({ ...build(target, props, options), startAt: baseAt })
    },
  }
  builderFn(proxy)
  return this
}

完成したタイムラインで作るヒーロー演出

const tl = new Timeline()

// 1. ロゴが下から
tl.add(logo,  { y: 0, opacity: 1 }, { duration: 800, easing: Easings.easeOutCubic })

// 2. 同時にタイトル文字を 1 文字ずつ
const chars = title.querySelectorAll(".char")
chars.forEach((c, i) => {
  tl.add(c, { opacity: 1, y: 0 }, { duration: 400 }, `<${i * 0.04}`)
})

// 3. ボタンがふわっと
tl.add(button, { opacity: 1, scale: 1 }, {
  duration: 500,
  easing: Easings.easeOutBack,
}, "<-0.2")

// 4. 背景がパララックス
tl.add(bg, { y: -20 }, {
  duration: 2000,
  easing: Easings.easeOutQuad,
}, 0)  // 絶対時刻 0(タイムライン頭)

tl.play()

スクロール連動タイムライン

スクロール位置をタイムラインの進行にマッピングする「ScrollTrigger」風:

function scrollTimeline(tl, { trigger, start = 0, end = "100vh" }) {
  const triggerEl = document.querySelector(trigger)
  const startPx = parsePx(start)
  const endPx = parsePx(end)

  function onScroll() {
    const rect = triggerEl.getBoundingClientRect()
    const total = endPx - startPx
    const offset = -rect.top - startPx
    const progress = Math.max(0, Math.min(offset / total, 1))
    tl.pause()
    tl.seek(progress * tl.duration)
  }

  window.addEventListener("scroll", onScroll, { passive: true })
  onScroll()
}

パフォーマンス

seek 時の落とし穴

過去にジャンプしたとき、from を再評価しないと値がズレる。 実装パターンとしては絶対値(from / to)を全トラックで先に確定させるか、 seek 時に「現在の値」をそのまま from として扱う。

Promise 統合

class Timeline {
  then(...args) {
    return new Promise((resolve) => this.on("complete", resolve)).then(...args)
  }
}

await tl.play()
console.log("done")

GSAP / motion との比較

自作GSAPmotion
サイズ~100 行~50KB~10KB
機能必要十分圧倒的軽量・モダン
学習コスト0
商用利用条件あり○ MIT
スクロール / SVG / morph自作要プラグインありシンプルなものは内蔵
タイムラインを使うとき

3 つ以上のトゥイーンが時系列で絡むと Promise だと辛くなる。 そこで自作 or 既製のタイムラインに切り替える。スクラブできる UI も同時に得られる。