タイムライン
複数のトゥイーンを時系列に並べて、まとめて再生・一時停止・スクラブする仕組み。
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()
用語
- at(位置指定): そのトゥイーンをいつ始めるか
- "+=N": 直前の終了から N ms 後
- "-=N": 直前の終了から N ms 前(オーバーラップ)
- "<": 直前と同時に始める
- ">": 直前の終了直後
- 絶対値(数値): タイムライン頭からの絶対時刻
- label(文字列): 名前付き位置
最小実装
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()
}
パフォーマンス
- 1 タイムラインに含まれるトラック数が多くても、各フレームの線形走査は通常 O(N) で OK
- 非アクティブ(startAt > elapsed)のトラックは早めに skip
- 大量にネストした場合はスパース表現を検討
- seek 時の整合性: from が確定済みかチェック
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 との比較
| 自作 | GSAP | motion | |
|---|---|---|---|
| サイズ | ~100 行 | ~50KB | ~10KB |
| 機能 | 必要十分 | 圧倒的 | 軽量・モダン |
| 学習コスト | 0 | 中 | 低 |
| 商用利用 | ○ | 条件あり | ○ MIT |
| スクロール / SVG / morph | 自作要 | プラグインあり | シンプルなものは内蔵 |
3 つ以上のトゥイーンが時系列で絡むと Promise だと辛くなる。 そこで自作 or 既製のタイムラインに切り替える。スクラブできる UI も同時に得られる。