スプリング(バネ系アニメーション)
トゥイーンは「時間 → 値」の関数。スプリングは「物理シミュレーション」。 途中で目標値が変わっても自然に追従する。React Spring / Framer Motion の心臓部。
トゥイーンとの違い
| Tween | Spring | |
|---|---|---|
| パラメータ | duration / easing | stiffness / damping / mass |
| 終わり | 固定(duration で確定) | 収束次第(速度がほぼ 0 で停止) |
| 途中で目標変更 | 不自然 | 自然に追従 |
| 振動 | イージングで擬似的 | 物理的に自然 |
| 得意 | 演出 | UI(ジェスチャ・ドラッグ追従) |
物理モデル
質量 m の物体がバネで目標位置に繋がっている。バネの引力 + 抗力(減衰)で運動する。
現在位置 x 目標位置 t
●——/\/\/\/\——●
←─ 力 ─→
力 = -k(x - t) (バネの復元力)
抗力 = -c × v (速度に比例した減衰)
加速度 a = (力 + 抗力) / m
- k (stiffness): バネの剛性。大きいほど強く戻る
- c (damping): 減衰係数。大きいほど早く止まる
- m (mass): 質量。大きいほどもったり
最小スプリング
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)`,
})
パラメータの感覚
| 用途 | stiffness | damping | mass |
|---|---|---|---|
| UI 標準 | 170 | 26 | 1 |
| ゆっくり | 80 | 15 | 1 |
| キビキビ | 300 | 30 | 1 |
| 跳ねる | 200 | 10 | 1 |
| もったり | 100 | 15 | 2 |
減衰比(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(過小減衰)で十分。臨界 / 過減衰は跳ねさせたくない場合に使う。
パフォーマンス
- 1 ステップあたりの計算量は微小。1000 個並列でも 60fps 余裕
- 固定 STEP の内部ループに注意(dt が大きいときに反復回数が増える)
- 収束判定で停止できるなら停止する(rAF を切る)
応用: ボタンの「跳ねる」演出
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 |
| 跳ねる UI | Spring(or easeOutBack) |
| マウス追従カーソル | damp / Spring |
| ページ遷移 | Tween |
物理ベースの動きは「慣性」を伴うため、人間が触るインタフェースに自然になじむ。 ジェスチャ連動 / ドラッグ / カーソル / モーダルの登場はスプリング推奨。 Apple のシステム UI もほぼスプリング。