追従
追従の作り方
右から左へ振れる i のフォロースルー。遅れも跳ねも、正体は片道の振り 1 本と、最後のキーを振り抜く速さがそのまま決める揺れだ。速く振り抜くほど揺れは大きく、遅れも行き過ぎもそこから生まれる。コードつきで、組み立ての考え方から順に見ていく。
- Published
- 2026年6月13日
- Topics
- Follow-through · Overshoot · Easing · SVG
片道の振り 1 本に、勢いで出る揺れを足す
- 片道の振り —
keyedLegが片道ぶんのキー移動を返す(最初のキーが出発点、最後が到着点、あいだは緩急で補間) - 勢いで出る揺れ — 最後のキーを振り抜く速さ
exitSpeedが、行って戻る揺れの大きさをそのまま決める(揺れ幅を別に決める数は無い) - 片道で 1 周を閉じる — 後半は前半の左右反転なので author するのは片道だけ、
channelAtがここまでをまとめる - 3 本のチャンネル — 茎の上
topMove・茎の下botMove・点dotMoveがそれぞれ x を動かし、glyphAtが茎と点に組む
主役は 2 つの関数 channelAt と glyphAt だ。channelAt は 1 本のチャンネルの位置を返し、glyphAt がそれを 3 本ぶん呼んで茎の両端と点に組む。脇に小さな助けがいくつか付く — 片道のキー移動を返す keyedLeg、振り抜く速さを出す exitSpeed、フレーム番号を 0〜loopFrames に巻き戻す wrap だ。
// 動かしているのはキー移動 1 本と、その勢いで出る揺れ。残りは全部その読み替え。
// 小文字ではじまる裸の名前(loopFrames や settleRate)は、自分の画面に合わせて決める数 —
// この記事が持ち帰ってほしいのは値ではなく、この「形」だ。
const wrap = (value, span) => ((value % span) + span) % span;
const exitSpeed = (move) => {
const keys = move.keys;
const last = keys[keys.length - 1];
const prev = keys[keys.length - 2];
const [, , handleX, handleY] = move.eases[move.eases.length - 1]; // 最終区間の緩急を決める数
const slope = (1 - handleY) / (1 - handleX); // キーを離れる瞬間の傾き
return (slope * (last.x - prev.x)) / (last.t - prev.t);
};
const keyedLeg = (tau, move) => { // 片道のキー移動。時刻 tau がどの区間にいるかを探し、その区間の緩急で位置を返す
const keys = move.keys;
if (tau < keys[0].t) return keys[0].x; // 動きはじめる前 — 出発点で待つ
const seg = keys.findIndex((k, i) => i > 0 && tau < k.t); // tau を追い越す最初のキー
if (seg < 0) return keys[keys.length - 1].x; // 動き終わったあと — 到着点で待つ
const from = keys[seg - 1];
const to = keys[seg];
const ease = cubicBezier(...move.eases[seg - 1]);
return from.x + (to.x - from.x) * ease((tau - from.t) / (to.t - from.t));
};
function channelAt(frame, move) { // 1 本のチャンネルの位置: 片道の振り + 振り抜く速さで出る揺れ。loopFrames ごとに繰り返す
const half = loopFrames / 2;
const t = wrap(frame, loopFrames);
const restA = move.keys[0].x;
const restB = move.keys[move.keys.length - 1].x;
const endTime = move.keys[move.keys.length - 1].t;
const keyed = t < half ? keyedLeg(t, move) : restA + restB - keyedLeg(t - half, move); // 後半は前半を左右反転して再生 — 片道だけ author すれば 1 周が閉じる
const speed = exitSpeed(move);
// 揺れ: 行って戻るサインがだんだん小さくなりながら収まる。大きさは振り抜く速さで決まる
// 両端の到着ぶんを足す(後半は符号を反転)。1 周ぶん前まで読んで前周の残りも拾う
const arrivals = [[endTime, 1], [endTime + half, -1]];
return arrivals.flatMap(([arrival, sign]) =>
[0, 1].map((lap) => {
const d = wrap(t - arrival, loopFrames) + lap * loopFrames;
return sign * ((speed * Math.exp(-move.settleDecay * d) * Math.sin(move.settleRate * d)) / move.settleRate);
})
).reduce((sum, ring) => sum + ring, keyed);
}
function glyphAt(frame) { // フレーム番号を渡すと、茎の両端と点の置き場所を返す。3 本のチャンネルがそれぞれ x を動かす
return {
stemTop: { x: channelAt(frame, topMove), y: capTopY },
stemBot: { x: channelAt(frame, botMove), y: capBotY },
stemWidth,
dot: { cx: channelAt(frame, dotMove), cy: dotCy, r: dotRadius },
};
}緩急は区間ごとに 1 つずつ持つ。keyedLeg は時刻がどのキーの区間にいるかを findIndex で探し、その区間の数で cubicBezier を作って補間する。これは CSS の cubic-bezier() に渡す 4 つの数とまったく同じで、bezier-easing のような既製のライブラリ がそのまま使える。出発点より前の時刻では出発点に、到着点を過ぎた時刻では到着点に、静かに留まる。
channelAt は片道の振りを 1 回 author するだけで 1 周を閉じる。ループの前半は片道をそのまま、後半は前半を左右反転して再生する(restA と restB を足して片道を引く)。だから右から左へ振って、左でひと息ついて、また右へ、という 1 周ぶんの動きが、半分の手間で出る。
この動きの芯は揺れの作り方だ。exitSpeed は、最後のキーを離れる瞬間の傾き(緩急の数から出る)に、そのキーで動いた距離と時間を掛けて、振り抜く速さを出す。その速さを行って戻るサインに掛けると、揺れはだんだん小さくなりながら止まりにいく — 揺れ幅を決めるつまみはどこにも無い。だから勢いよく止まりにいった点は大きく跳ね、そっと止まった茎の下はほとんど跳ねない。同じ 1 つの仕組みから、跳ねの大小が自然に分かれる。
glyphAt は channelAt を 3 回呼ぶ。茎の上 topMove・茎の下 botMove・点 dotMove が、それぞれ別のキーと別の揺れの収まり方を持つ。遅れは、点の動きが他より遅れて出発するだけで出る — 別に「遅らせる」コードは無い。しなりも、茎の上と下が動いているあいだだけ少し違う x にいるだけで、曲がった芯を持っているわけではない。止まればどちらも同じ位置にそろい、棒は真っ直ぐにもどる。
描くほうは、茎を丸キャップの太い線、点を円にするだけだ。SVG でも canvas でもいい。色は 1 つ、stemWidth は線の太さ、点の白い目は元の文字の飾りなのでここでは描かない。届かないのは揺れの分け方で、3 本のチャンネルは収まり方 settleDecay と settleRate もそれぞれ別の数を持つ。1 組にまとめて共有すると、茎の上の端だけ揺れが足りなくなる。
動かすときは requestAnimationFrame で経過秒に毎秒のコマ数を掛けて frame を作る。継ぎ目が見えないのは、ちょうど片道ぶんで 1 周が閉じるからだ。後半は前半の左右反転なので、片道を 1 回往復した時点で元の絵にぴったり重なる。振りはじめの右端と振り終わりの右端が同じ姿勢になり、点も茎も同じ位置にそろう。
自分の数で組むときは、まず 3 本のキーの出発をずらす — 点をいちばん遅く出すと、いちばん大きく跳ねて尾を引く。終わりのキーはほぼ同じ時刻にそろえると、ばらけずに 1 つの動きに見える。揺れの settleDecay を大きくすると早く収まり、settleRate を変えると跳ねの細かさが変わる。cubicBezier の数を急にするほど、振り抜く速さが増えて跳ねも大きくなる。
channelAt が返すのはただの位置の数だから、文字でなくても動かせる。研究リポの汎用デモでは、縦に並べた 4 本のチャンネルを別のキー・別の揺れで走らせ、同じ仕組みで鎖のように後ろが追いかけた — 遅く出して勢いよく止まるものほど大きく跳ねる、という同じ流れがそのまま出た。揺れ幅をひとつも書いていないのに、跳ねの大小が勝手にそろう。