Arcs(弧)
生き物の動きは直線ではなく弧を描きます。arc は 2 つのポーズの間を二次ベジェ曲線に沿って動く MotionSpec を作り、arcPoint は同じ曲線上の任意の点を返します。
アニメーションにおける原則
Section titled “アニメーションにおける原則”人や動物の関節は回転運動をするので、手足の先端は必ず円弧を描きます。腕を振る、振り返る、歩くときに頭が上下するといった動きは、どれも中割りを直線上ではなく弧の上に置くことで自然になります。直線上に中割りを置くと、動きは硬く、機械的に見えます。
投げたボールは放物線を描き、振り子は円弧を往復します。顔を左から右へ向けるときも、頭は水平に滑るのではなく、途中でわずかに下がる(または上がる)弧をたどります。アニメーターはキーポーズの間に弧のガイド線を引き、中割りをその上に配置します。
例外は機械です。ピストンやエレベーター、ロボットの直線的な動きは、直線であることがそのまま「機械らしさ」の表現になります。また動きが速いほど弧は平らになり、ゆっくりした動きほど弧がはっきり見えます。
UI への翻訳
Section titled “UI への翻訳”UI では、要素がある場所から離れた別の場所へ「移る」ときに弧が効きます。
- カートへの追加、お気に入りへの保存: 商品画像のサムネイルがカートアイコンへ飛び込む。投げた物のように上に膨らむ弧が合います。
- FAB やメニューの展開: ボタンから項目が弧を描いて広がると、どこから出てきたかが追いやすくなります。
- ドラッグの取り消し: ドロップ先が無効なとき、元の位置へ弧を描いて戻ります。
bend は移動距離に対する膨らみの割合で、既定値 0.2 は 320px の移動で 64px の膨らみです。UI では 0.1〜0.3 が目安で、デモは 0.3 を使っています。移動距離が 100px 前後以下の小さな動き、画面内の多くの要素が同時に動くレイアウト変化(並べ替えやグリッドの再配置)、スクロール連動の動きでは直線のままにします。弧は視線を引くので、同時に何本も走ると散らかって見えます。
function arc(from: Pose, to: Pose, options?: ArcOptions): MotionSpec;| オプション | 型 | 既定値 | 説明 |
|---|---|---|---|
bend |
number |
0.2 |
直線からの膨らみの最大量を、移動距離に対する割合で指定。正で画面上方向、負で下方向 |
orient |
boolean |
false |
経路の接線に合わせて要素を回転させる(投げた物、紙飛行機) |
samples |
number |
24 |
経路の分割数。フレーム数は samples + 1 |
duration |
number |
travelDuration(距離) |
再生時間(ms)。既定は 2 点間の直線距離から算出(150〜300ms) |
delay |
number |
なし | 開始までの遅延(ms) |
easing |
Easing |
easings.inOut |
経路上の進み具合。イテレーション全体に掛かる |
動作の詳細:
from/toのx/yを端点に使います。省略した軸は 0 とみなします。- 曲線上を
t = i / samplesで等分割し、各フレームのx/yを曲線上の点に置きます。scale、opacityなど他のキーはfromからtoへ線形に補間され、同じeasingで一緒に進みます。 - フレーム自体には
easingを付けず、spec のeasingで全体を 1 回だけ加減速します。easingが文字列なら焼き込みなしで 25 枚のキーフレームとして WAAPI に渡ります。 - 分割は曲線のパラメータ
tで等間隔で、弧長では等間隔ではありません。二次曲線は端点付近で速く、頂点付近で遅く進むため、投げた物が頂点で一瞬とどまるような間が自然に生まれます。bendが大きいほど顕著です。
既定の duration は travelDuration による距離依存の値です。平方根で伸びるので、長い移動でも間延びしません。
| 直線距離 | 既定の duration |
|---|---|
| 50px | 188ms |
| 220px | 229ms |
| 400px | 256ms |
| 800px 以上 | 300ms |
弧の長さではなく直線距離から求めるため、bend を大きくしても既定の尺は変わりません。
bend の符号
Section titled “bend の符号”曲線の制御点は、直線の中点から直線に垂直な方向へ置かれます。その向きは移動方向によらず決まります。
- 正の
bendは 画面の上方向(yが小さくなる側)へ膨らみます。左へ移動しても右へ移動しても上です。 - 真上・真下への移動では「上」がないので、正の
bendは 左方向(xが小さくなる側)へ膨らみます。 - 負の
bendはその反対(下、または右)へ膨らみます。 - 膨らみの頂点は直線の中点から
bend × 距離の位置です(制御点はその 2 倍離れた位置に置かれます)。
| 始点 → 終点 | bend |
曲線の中点(t = 0.5) |
|---|---|---|
| (0, 0) → (220, 0) | 0.3 | (110, -66) |
| (220, 0) → (0, 0) | 0.2 | (110, -44) |
| (0, 0) → (0, 200) | 0.2 | (-40, 100) |
orient
Section titled “orient”orient: true のとき、各フレームの rotate を「経路の接線が直線(始点から終点への向き)からどれだけずれているか」に設定します。値は -180〜180 度の範囲に収まり、from.rotate に加算されます。デモの設定(右へ 220px、bend: 0.3)では、出発時に -50.2 度(機首が上)、頂点で 0 度、到着時に 50.2 度(機首が下)です。
- 回転は直線の向きを基準にした相対値なので、要素は「移動方向を向いた状態」で描いておきます。右へ飛ばす紙飛行機なら機首が右を向いた絵です。
to.rotateはorientの回転で上書きされるため効きません。- 始点と終点が同じ位置のときは回転しません。
arcPoint
Section titled “arcPoint”function arcPoint(a: Point, b: Point, bend: number, t: number): Point;| 引数 | 型 | 説明 |
|---|---|---|
a |
Point |
始点 { x, y } |
b |
Point |
終点 { x, y } |
bend |
number |
arc と同じ膨らみの割合と符号 |
t |
number |
曲線のパラメータ(0 = a、1 = b) |
arc が内部で使うのと同じ曲線上の点を返します。経路を SVG で描いてプレビューする、軌跡にパーティクルを置く、キャンバスに描画するといった用途に使えます。a と b が同じ点なら常に a を返します。
import { arc, arcPoint, play } from "twelve-principles";
// サムネイルの複製をカートアイコンへ弧を描いて飛ばすexport async function flyToCart(thumb: HTMLElement, cart: HTMLElement) { const from = thumb.getBoundingClientRect(); const to = cart.getBoundingClientRect(); const ghost = thumb.cloneNode(true) as HTMLElement; Object.assign(ghost.style, { position: "fixed", left: `${from.left}px`, top: `${from.top}px`, width: `${from.width}px`, height: `${from.height}px`, margin: "0", pointerEvents: "none", }); document.body.append(ghost);
const dx = to.left + to.width / 2 - (from.left + from.width / 2); const dy = to.top + to.height / 2 - (from.top + from.height / 2); // 投げた物のように上へ膨らませ、縮みながら消える const spec = arc({ x: 0, y: 0 }, { x: dx, y: dy, scale: 0.2, opacity: 0 }, { bend: 0.25 }); await play(ghost, spec).finished; ghost.remove();}
// 同じ曲線を SVG の path として描く(デバッグ・デザイン確認用)export function drawArc(path: SVGPathElement, a: { x: number; y: number }, b: { x: number; y: number }, bend = 0.25) { const points = Array.from({ length: 25 }, (_, i) => arcPoint(a, b, bend, i / 24)); path.setAttribute("d", points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" "));}import { arc } from "twelve-principles";import { useMotion } from "twelve-principles/react";
type Offset = { x: number; y: number };
// cartOffset: ボタンの中心からカートアイコンの中心までの相対位置(px)export function AddToCartButton({ cartOffset, onAdd }: { cartOffset: Offset; onAdd: () => void }) { const dot = useMotion<HTMLSpanElement>(); const add = () => { onAdd(); // persist: false で、飛び終えたら CSS の状態(非表示)に戻す dot.play( arc({ x: 0, y: 0, opacity: 1 }, { ...cartOffset, scale: 0.4, opacity: 0 }, { bend: 0.3 }), { persist: false }, ); }; return ( <button className="add-to-cart" onClick={add}> カートに追加 <span ref={dot.ref} className="add-to-cart-dot" aria-hidden="true" /> </button> );}
// 紙飛行機: 機首が右を向いた絵を、接線に沿って回転させながら飛ばすexport function PaperPlane() { const plane = useMotion<HTMLDivElement>(); return ( <div className="runway"> <div ref={plane.ref} className="plane" /> <button onClick={() => plane.play(arc({ x: 0 }, { x: 220 }, { bend: 0.3, orient: true, duration: 700 }))}> 飛ばす </button> </div> );}ガイドラインと落とし穴
Section titled “ガイドラインと落とし穴”- Slow In & Slow Out:
easingが経路上の進み具合を決めます。入場ならeasings.out、退場ならeasings.inを渡します。 - Timing: 既定の
durationはtravelDurationです。距離が変わっても一貫した速度感になります。 - Exaggeration:
exaggerate(arc(...), 1.3)は REST 基準で逸脱量を拡大するので、移動距離と膨らみがまとめて 1.3 倍になります。 - Squash & Stretch: 到着時の潰れは別の要素(または後続の
play)で表現します。 - Follow Through & Overlapping: 弧全体を弾ませるにはスプリングを
easingに使います。