スプリング(バネ系アニメーション)

トゥイーンは「時間 → 値」の関数。スプリングは「物理シミュレーション」。 途中で目標値が変わっても自然に追従する。React Spring / Framer Motion の心臓部。

トゥイーンとの違い

TweenSpring
パラメータduration / easingstiffness / damping / mass
終わり固定(duration で確定)収束次第(速度がほぼ 0 で停止)
途中で目標変更不自然自然に追従
振動イージングで擬似的物理的に自然
得意演出UI(ジェスチャ・ドラッグ追従)

物理モデル

質量 m の物体がバネで目標位置に繋がっている。バネの引力 + 抗力(減衰)で運動する。

   現在位置 x        目標位置 t
       ●——/\/\/\/\——●
       ←─ 力 ─→

  力 = -k(x - t)         (バネの復元力)
  抗力 = -c × v          (速度に比例した減衰)
  加速度 a = (力 + 抗力) / m
      

最小スプリング

function spring({
  from, to, stiffness = 170, damping = 26, mass = 1,
  velocity = 0, precision = 0.01, onUpdate,
}) {
  return new Promise((resolve) => {
    let x = from
    let v = velocity
    let prev = performance.now()

    function tick(now) {
      const dt = Math.min((now - prev) / 1000, 0.064)  // 上限
      prev = now

      // 加速度
      const force = -stiffness * (x - to)
      const drag  = -damping * v
      const a = (force + drag) / mass

      v += a * dt
      x += v * dt

      onUpdate(x)

      const isResting = Math.abs(v) < precision && Math.abs(x - to) < precision
      if (isResting) {
        onUpdate(to)  // ピッタリ合わせて終わる
        resolve()
      } else {
        requestAnimationFrame(tick)
      }
    }
    requestAnimationFrame(tick)
  })
}

spring({
  from: 0, to: 200,
  onUpdate: (v) => box.style.transform = `translateX(${v}px)`,
})

パラメータの感覚

用途stiffnessdampingmass
UI 標準170261
ゆっくり80151
キビキビ300301
跳ねる200101
もったり100152

減衰比(damping ratio)

理論的にはdamping ratio ζ で挙動が決まる:

ζ = damping / (2 × √(stiffness × mass))

ζ < 1   過小減衰 — 跳ねる
ζ = 1   臨界減衰 — 跳ねずに最速で収束
ζ > 1   過減衰   — 滑らかにゆっくり
      

UI では0.6〜0.9 の過小減衰が「気持ちよく弾む」感じになる。

解析解(exact spring)

数値積分の代わりに解析的に位置を計算する方法もある。 Framer Motion / React Spring は内部でこっち。安定でフレームレートに依存しない

function springExact({ from, to, velocity = 0, stiffness = 170, damping = 26, mass = 1 }) {
  const w0 = Math.sqrt(stiffness / mass)
  const zeta = damping / (2 * Math.sqrt(stiffness * mass))
  const wd = w0 * Math.sqrt(1 - zeta * zeta)  // 減衰角振動数
  const A = from - to
  const B = (velocity + zeta * w0 * A) / wd

  return function(t) {
    if (zeta < 1) {
      // 過小減衰
      return to + Math.exp(-zeta * w0 * t) *
        (A * Math.cos(wd * t) + B * Math.sin(wd * t))
    } else {
      // 臨界減衰 / 過減衰(簡略)
      return to + Math.exp(-w0 * t) * (A + (velocity + w0 * A) * t)
    }
  }
}

const fn = springExact({ from: 0, to: 100 })
fn(0)    // 0
fn(0.5)  // ある時点の位置

この関数を時間で評価していけば数値積分と同等で、しかもジャンプ可能(任意 t での値が得られる)。

Spring クラス(連続更新対応)

UI で重要なのは「動いている途中で目標が変わったら追従」

class Spring {
  constructor({ stiffness = 170, damping = 26, mass = 1 } = {}) {
    this.k = stiffness
    this.c = damping
    this.m = mass
    this.x = 0
    this.v = 0
    this.target = 0
    this.precision = 0.01
  }

  set(value) {
    this.x = value
    this.v = 0
  }

  to(target) {
    this.target = target
  }

  update(dt) {
    const force = -this.k * (this.x - this.target)
    const drag = -this.c * this.v
    const a = (force + drag) / this.m
    this.v += a * dt
    this.x += this.v * dt
    return this.x
  }

  isResting() {
    return Math.abs(this.v) < this.precision
        && Math.abs(this.x - this.target) < this.precision
  }
}

// 使い方: マウス追従
const sx = new Spring({ stiffness: 100, damping: 15 })
const sy = new Spring({ stiffness: 100, damping: 15 })

window.addEventListener("mousemove", (e) => {
  sx.to(e.clientX)
  sy.to(e.clientY)
})

let prev = performance.now()
function loop(now) {
  const dt = (now - prev) / 1000
  prev = now
  sx.update(dt)
  sy.update(dt)
  cursor.style.transform = `translate(${sx.x}px, ${sy.y}px)`
  requestAnimationFrame(loop)
}
requestAnimationFrame(loop)

固定タイムステップで安定化

スプリングはステップが大きいと発散することがある。固定タイムステップで安定化:

class Spring {
  // ...
  update(dt) {
    const STEP = 1 / 240  // 240Hz の精度で内部計算
    let acc = dt
    while (acc > 0) {
      const step = Math.min(acc, STEP)
      const a = (-this.k * (this.x - this.target) - this.c * this.v) / this.m
      this.v += a * step
      this.x += this.v * step
      acc -= step
    }
    return this.x
  }
}

ジェスチャ連動: ドラッグして離す

ドラッグ中は手動で位置を上書き、離した瞬間に速度を保持してスプリングに戻す。

const s = new Spring({ stiffness: 200, damping: 20 })
let dragging = false
let lastTime = 0
let lastX = 0
let velocity = 0

box.addEventListener("pointerdown", (e) => {
  dragging = true
  lastTime = performance.now()
  lastX = e.clientX
})

window.addEventListener("pointermove", (e) => {
  if (!dragging) return
  const now = performance.now()
  velocity = (e.clientX - lastX) / ((now - lastTime) / 1000)
  lastTime = now
  lastX = e.clientX
  s.set(e.clientX)  // ドラッグ中は位置を直接書き換え
})

window.addEventListener("pointerup", () => {
  dragging = false
  s.v = velocity   // 離した瞬間の速度をスプリングに渡す
  s.to(0)          // 元の位置に戻す
})

function loop(now) {
  if (!dragging) s.update(0.016)
  box.style.transform = `translateX(${s.x}px)`
  requestAnimationFrame(loop)
}
requestAnimationFrame(loop)

多次元スプリング

class SpringVector {
  constructor(dim, options) {
    this.springs = Array.from({ length: dim }, () => new Spring(options))
  }
  set(arr) { arr.forEach((v, i) => this.springs[i].set(v)) }
  to(arr)  { arr.forEach((v, i) => this.springs[i].to(v)) }
  update(dt) { return this.springs.map(s => s.update(dt)) }
  isResting() { return this.springs.every(s => s.isResting()) }
}

const pos = new SpringVector(2, { stiffness: 150, damping: 20 })
pos.to([100, 50])
const [x, y] = pos.update(0.016)

カラーをスプリングする

RGB 各成分にスプリング:

const r = new Spring()
const g = new Spring()
const b = new Spring()

function setColor({ r: tr, g: tg, b: tb }) {
  r.to(tr); g.to(tg); b.to(tb)
}

setColor({ r: 255, g: 100, b: 50 })

function loop(now) {
  r.update(dt); g.update(dt); b.update(dt)
  el.style.color = `rgb(${r.x|0}, ${g.x|0}, ${b.x|0})`
  requestAnimationFrame(loop)
}

Friction(摩擦)系

damping なしで速度だけ減衰させる「decay」モデルも UX で使う。 スワイプ後の慣性スクロールなど。

function decay({ from, velocity, friction = 0.95 }) {
  let x = from
  let v = velocity
  let prev = performance.now()
  return new Promise((resolve) => {
    function tick(now) {
      const dt = (now - prev) / 1000
      prev = now
      v *= Math.pow(friction, dt * 60)  // 60fps 基準
      x += v * dt

      if (Math.abs(v) < 0.5) { resolve(x); return }
      requestAnimationFrame(tick)
    }
    requestAnimationFrame(tick)
  })
}

React-Spring 風 API

宣言的に書きたい:

const s = useSpring({ x: 0 })

// クリックで動かす
button.onclick = () => s.start({ x: 200 })

// レンダリング: s.x.get() を read

実装的には Spring + Subscriber パターン:

function createSpring(initial, options) {
  const s = new Spring(options)
  s.set(initial)
  const subs = new Set()
  function start(target) { s.to(target) }
  function get() { return s.x }
  function subscribe(fn) { subs.add(fn); return () => subs.delete(fn) }

  // グローバルループに参加
  TweenManager.add({
    update(dt) {
      s.update(dt)
      subs.forEach(fn => fn(s.x))
    }
  })

  return { start, get, subscribe }
}

過減衰の実装

ζ ≥ 1 のとき振動しない。臨界減衰の式:

x(t) = e^(-ω₀t) × [A + (v + ω₀A)t]   (臨界 ζ=1)
x(t) = e^(-ζω₀t) × [A cosh(ω_d t) + B sinh(ω_d t)]   (過減衰 ζ>1)
      

UI のほとんどは ζ < 1(過小減衰)で十分。臨界 / 過減衰は跳ねさせたくない場合に使う。

パフォーマンス

応用: ボタンの「跳ねる」演出

button.addEventListener("click", () => {
  const s = new Spring({ stiffness: 400, damping: 12 })
  s.x = 1.2  // クリックで瞬間スケール
  s.to(1)

  let prev = performance.now()
  function loop(now) {
    const dt = (now - prev) / 1000
    prev = now
    s.update(dt)
    button.style.transform = `scale(${s.x})`
    if (!s.isResting()) requestAnimationFrame(loop)
  }
  requestAnimationFrame(loop)
})

応用: スワイプで遷移

ドラッグ量がしきい値を超えたら次のページへ、超えなければ戻す。速度も判定に使う:

window.addEventListener("pointerup", () => {
  const flickStrength = velocity / 1000 + dragX
  const target = flickStrength > SCREEN_WIDTH * 0.5
    ? SCREEN_WIDTH   // 次のページへ
    : 0              // 戻る
  s.v = velocity
  s.to(target)
})

WAAPI の "spring" イージング

2024 年以降の Chrome では linear() イージングで spring を表現できるようになった:

// CSS でスプリング風(近似)
animation: bounce 1s linear(0, 0.5 12.5%, 0.95 25%, 1.1 37.5%, 1 50%, 0.95 62.5%, 1);

本物のスプリングではないが、事前計算した値を打点することで似た見た目になる。 Framer Motion はこの手法でブラウザネイティブに乗せている。

Spring vs Tween のいつ使い分けか

条件選択
固定時間で確定したいTween
ジェスチャ・連続入力Spring
シーケンス演出Tween + Timeline
跳ねる UISpring(or easeOutBack)
マウス追従カーソルdamp / Spring
ページ遷移Tween
スプリングは UI の正解

物理ベースの動きは「慣性」を伴うため、人間が触るインタフェースに自然になじむ。 ジェスチャ連動 / ドラッグ / カーソル / モーダルの登場はスプリング推奨。 Apple のシステム UI もほぼスプリング。