Timing(タイミング)
同じ移動でも、何ミリ秒かけるかで「重い」「軽い」「もたつく」「きびきび」の印象が決まります。UI では時間はそのまま応答性であり、ユーザーが起こした動きは 300ms 以内に終えるのが基本です。
アニメーションにおける原則
Section titled “アニメーションにおける原則”手描きアニメーションでのタイミングは「その動作に何コマを割り当てるか」です。24fps のフィルムなら、12 コマの動作は 0.5 秒、6 コマなら 0.25 秒になります。コマが多いほど動きは遅く、少ないほど速く見えます。
タイミングは速さだけでなく、重さと感情も伝えます。重い物体は動き出しと止まりに時間がかかり、軽い物体はすぐに加速し、すぐに止まります。同じ首振りでも、ごく短ければ何かに叩かれたように、少し長ければ振り向いたように、さらに長ければゆっくり周囲を見回しているように読めます。描く絵が同じでも、割り当てる時間だけで演技が変わります。
似た言葉に「スペーシング」があります。タイミングが総コマ数(総時間)を決め、スペーシングがその中でのコマの配置(どこで速く、どこで遅いか)を決めます。本ライブラリではタイミングを duration、スペーシングをイージング(Slow In & Slow Out)として分けて扱います。
UI への翻訳
Section titled “UI への翻訳”UI の動きは演出である前にフィードバックです。押下やホバーへの反応が遅いと、操作が効いていないように感じられます。本ライブラリの既定値は次の考え方に沿っています。
- ユーザー起点の動きは 300ms 以内(
USER_INITIATED_MAX_MS = 300)。押下 100ms、ホバー 200ms、入場 200ms(popは 300ms)、退場 150ms。 - 退場は入場より短い。消える要素を目で追う必要はないため、
exitは 150ms、enterは 200ms。 - システム起点の演出だけが長くてよい。オンボーディングや空状態のイラストなど、ユーザーが待っていない動きには
deliberate(500ms)を使えます。 - 移動距離が長いほど長く、ただし比例させない。距離に比例させると大きな移動が間延びし、小さな移動がせわしなくなります。
travelDurationは距離の平方根で尺を決めます。 - スタッガーは 1 項目あたり 50ms 以内(
MAX_STAGGER_MS = 50、StaggerOptions.capの既定値)。それを超えるとリストが「順に現れる」ではなく「遅い」と読まれます。見せるためのカスケードではcapを明示して上げられます。
尺はすべて Personality の tempo で一括して伸縮します。tempo を掛けたあとの値は次のとおりです(duration(token, tempo) の結果)。
| トークン | 既定 | snappy(0.8) |
calm(1.25) |
|---|---|---|---|
instant |
100 | 80 | 125 |
fast |
150 | 120 | 188 |
base |
200 | 160 | 250 |
slow |
300 | 240 | 375 |
deliberate |
500 | 400 | 625 |
natural と playful の tempo は 1 なので既定値のままです。calm の slow(375ms)は 300ms の目安を超えます。落ち着いた性格と引き換えに応答性を少し手放している、というトレードオフです。
durations と DurationToken
Section titled “durations と DurationToken”const durations = { instant: 100, fast: 150, base: 200, slow: 300, deliberate: 500,} as const;
type DurationToken = keyof typeof durations; // "instant" | "fast" | "base" | "slow" | "deliberate"| トークン | ms | ライブラリ内での用途 |
|---|---|---|
instant |
100 | pressable の押し込み |
fast |
150 | exit、tiltable のポインタ追従 |
base |
200 | enter、hoverable の浮上、stage の解除、animateTo の既定尺 |
slow |
300 | enter("pop")、hoverable の戻り、stage の適用、poseToPose の既定尺 |
deliberate |
500 | ライブラリ内では未使用。システム起点の演出用 |
animateTo と poseToPose の既定尺は tempo を掛けない生の値です。
duration
Section titled “duration”function duration(value: DurationToken | number, tempo = 1): number;トークン名または ms に tempo を掛け、Math.round で整数にします。value が負・NaN・未知のトークン、または tempo が 0 以下のときは RangeError を投げます。
duration("base"); // 200duration("fast", 1.25); // 188duration(450, 0.8); // 360travelDuration
Section titled “travelDuration”function travelDuration(distancePx: number, options?: TravelOptions): number;| オプション | 型 | 既定値 | 説明 |
|---|---|---|---|
min |
number |
150 |
距離 0 のときの尺(ms) |
max |
number |
300(USER_INITIATED_MAX_MS) |
reference 以上の距離での尺(ms) |
reference |
number |
800 |
max に到達する距離(px) |
計算式は min + (max - min) * min(sqrt(abs(distancePx) / reference), 1) を丸めたものです。距離は絶対値で扱うため、負の移動量をそのまま渡せます。既定値での例:
| 距離(px) | 0 | 50 | 200 | 450 | 800 以上 |
|---|---|---|---|---|---|
| 尺(ms) | 150 | 188 | 225 | 263 | 300 |
平方根なので、200px の移動は 50px の 4 倍の距離でも尺は 1.2 倍にしかなりません。
staggerDelays
Section titled “staggerDelays”function staggerDelays(count: number, options?: StaggerOptions): number[];| オプション | 型 | 既定値 | 説明 |
|---|---|---|---|
each |
number |
30 |
隣り合う項目の遅延差(ms)。cap でクランプ |
cap |
number |
50(MAX_STAGGER_MS) |
1 項目あたりの遅延の上限(ms)。派手なカスケードでは上げる(例: 120〜150) |
total |
number |
300 |
起点から最も遠い項目の遅延の上限(ms) |
from |
"first" | "last" | "center" | number |
"first" |
カスケードの起点。数値はインデックス |
1 ステップの遅延は min(each, cap, total / 起点からの最大距離) です。項目数が増えると total に収まるよう自動的に間隔が詰まります。cap が負(または NaN)なら RangeError を投げます。
staggerDelays(5); // [0, 30, 60, 90, 120]staggerDelays(5, { from: "center" }); // [60, 30, 0, 30, 60]staggerDelays(3, { each: 100 }); // [0, 50, 100] (cap 50 でクランプ)staggerDelays(3, { each: 100, cap: 150 }); // [0, 100, 200]staggerDelays(6, { each: 120, cap: 150 }); // [0, 60, 120, 180, 240, 300] (total 300 が効く)staggerDelays(6, { each: 120, cap: 150, total: 900 }); // [0, 120, 240, 360, 480, 600]staggerDelays(20); // 間隔は 300 / 19 ≈ 15.8ms、最後の項目は 300total は最後に動き出す項目の遅延であり、カスケード全体の所要時間は total + 各項目の尺 になります。
staggerDistances
Section titled “staggerDistances”function staggerDistances(count: number, from?: "first" | "last" | "center" | number): number[];各項目が起点から何ステップ離れているかを返します(既定の from は "first")。staggerDelays と、Overlapping Action の overlap が内部で使っています。count が 0 以上の整数でなければ RangeError を投げます。
staggerDistances(4, "center"); // [1.5, 0.5, 0.5, 1.5]staggerDistances(3, "last"); // [2, 1, 0]| 定数 | 値 | 意味 |
|---|---|---|
USER_INITIATED_MAX_MS |
300 |
ユーザー起点の動きの上限の目安。travelDuration の max の既定値 |
MAX_STAGGER_MS |
50 |
1 項目あたりのスタッガーの上限の目安。StaggerOptions.cap の既定値として each をクランプする |
距離に応じた尺でインジケーターを動かす
Section titled “距離に応じた尺でインジケーターを動かす”タブの下線やスライダーのつまみのように、移動距離が毎回変わる要素に向いています。
import { animateTo, currentPose, easings, travelDuration } from "twelve-principles";
export function moveIndicator(indicator: HTMLElement, targetX: number): void { const fromX = currentPose(indicator).x ?? 0; animateTo(indicator, { x: targetX }, { transition: { duration: travelDuration(targetX - fromX), easing: easings.inOut }, });}import { useEffect, useRef } from "react";import { duration, easings, travelDuration } from "twelve-principles";import { useMotion, useMotionConfig } from "twelve-principles/react";
export function TabIndicator({ x }: { x: number }) { const { ref, animateTo } = useMotion<HTMLDivElement>(); const { personality } = useMotionConfig(); const previous = useRef(x);
useEffect(() => { // 距離で尺を決め、Personality の tempo も反映する const ms = duration(travelDuration(x - previous.current), personality.tempo); previous.current = x; animateTo({ x }, { transition: { duration: ms, easing: easings.inOut } }); }, [x, animateTo, personality.tempo]);
return <div ref={ref} className="tab-indicator" />;}animateTo は再生中の動きの現在位置から始まるため、連続してタブを切り替えても位置が飛びません。
検索結果を順に表示する
Section titled “検索結果を順に表示する”import { enter, play, staggerDelays } from "twelve-principles";
export function revealResults(list: HTMLElement): void { const items = Array.from(list.querySelectorAll<HTMLElement>(":scope > li")); const delays = staggerDelays(items.length, { each: 40, total: 240 }); const spec = enter("rise"); items.forEach((item, i) => play(item, { ...spec, delay: delays[i] ?? 0 }));}import { useCascade } from "twelve-principles/react";
export function ResultList({ results }: { results: string[] }) { // results が変わるたびに子要素が順に入場する const ref = useCascade<HTMLUListElement>("rise", { each: 40, total: 240, trigger: results }); return ( <ul ref={ref}> {results.map((r) => ( <li key={r}>{r}</li> ))} </ul> );}play は WAAPI の fill: "both" で再生するため、遅延中の項目は最初のポーズ(透明で 12px 下)のまま待機し、ちらつきません。useCascade は内部で overlap を使い、後ろの項目ほど少し弾むフォロースルーも加えます。
ガイドラインと落とし穴
Section titled “ガイドラインと落とし穴”- Slow In & Slow Out — タイミングが総時間を、イージングがその中の速度配分を決めます。尺だけ変えてイージングを変えないと、重さの違いは伝わりにくくなります。
- Follow Through & Overlapping Action —
overlapはstaggerDelaysで開始をずらし、staggerDistancesで後続項目の弾みを強めます。 - Anticipation —
anticipateの既定fit: "compress"は溜めを加えても総尺を変えないため、300ms の予算を守れます。 - Exaggeration — 移動量だけを誇張して尺を変えないと、速度が上がります。誇張と尺はセットで調整します。
- Appeal — Personality の
tempoがすべてのレシピと振る舞いの尺に掛かります。