コンテンツにスキップ

Timing(タイミング)

同じ移動でも、何ミリ秒かけるかで「重い」「軽い」「もたつく」「きびきび」の印象が決まります。UI では時間はそのまま応答性であり、ユーザーが起こした動きは 300ms 以内に終えるのが基本です。

LIVE
instant 100ms
fast 150ms
base 200ms
slow 300ms
deliberate 500ms
Play を押すと 5 つのトークン(100 / 150 / 200 / 300 / 500ms)で同じ 200px を移動する。もう一度押すと戻る。

手描きアニメーションでのタイミングは「その動作に何コマを割り当てるか」です。24fps のフィルムなら、12 コマの動作は 0.5 秒、6 コマなら 0.25 秒になります。コマが多いほど動きは遅く、少ないほど速く見えます。

タイミングは速さだけでなく、重さと感情も伝えます。重い物体は動き出しと止まりに時間がかかり、軽い物体はすぐに加速し、すぐに止まります。同じ首振りでも、ごく短ければ何かに叩かれたように、少し長ければ振り向いたように、さらに長ければゆっくり周囲を見回しているように読めます。描く絵が同じでも、割り当てる時間だけで演技が変わります。

似た言葉に「スペーシング」があります。タイミングが総コマ数(総時間)を決め、スペーシングがその中でのコマの配置(どこで速く、どこで遅いか)を決めます。本ライブラリではタイミングを duration、スペーシングをイージング(Slow In & Slow Out)として分けて扱います。

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 = 50StaggerOptions.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

naturalplayfultempo は 1 なので既定値のままです。calmslow(375ms)は 300ms の目安を超えます。落ち着いた性格と引き換えに応答性を少し手放している、というトレードオフです。

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 exittiltable のポインタ追従
base 200 enterhoverable の浮上、stage の解除、animateTo の既定尺
slow 300 enter("pop")hoverable の戻り、stage の適用、poseToPose の既定尺
deliberate 500 ライブラリ内では未使用。システム起点の演出用

animateToposeToPose の既定尺は tempo を掛けない生の値です。

function duration(value: DurationToken | number, tempo = 1): number;

トークン名または ms に tempo を掛け、Math.round で整数にします。value が負・NaN・未知のトークン、または tempo が 0 以下のときは RangeError を投げます。

duration("base"); // 200
duration("fast", 1.25); // 188
duration(450, 0.8); // 360
function travelDuration(distancePx: number, options?: TravelOptions): number;
オプション 既定値 説明
min number 150 距離 0 のときの尺(ms)
max number 300USER_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 倍にしかなりません。

function staggerDelays(count: number, options?: StaggerOptions): number[];
オプション 既定値 説明
each number 30 隣り合う項目の遅延差(ms)。cap でクランプ
cap number 50MAX_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、最後の項目は 300

total は最後に動き出す項目の遅延であり、カスケード全体の所要時間は total + 各項目の尺 になります。

function staggerDistances(count: number, from?: "first" | "last" | "center" | number): number[];

各項目が起点から何ステップ離れているかを返します(既定の from"first")。staggerDelays と、Overlapping Actionoverlap が内部で使っています。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 ユーザー起点の動きの上限の目安。travelDurationmax の既定値
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 },
});
}

animateTo は再生中の動きの現在位置から始まるため、連続してタブを切り替えても位置が飛びません。

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 }));
}

play は WAAPI の fill: "both" で再生するため、遅延中の項目は最初のポーズ(透明で 12px 下)のまま待機し、ちらつきません。useCascade は内部で overlap を使い、後ろの項目ほど少し弾むフォロースルーも加えます。

  • Slow In & Slow Out — タイミングが総時間を、イージングがその中の速度配分を決めます。尺だけ変えてイージングを変えないと、重さの違いは伝わりにくくなります。
  • Follow Through & Overlapping ActionoverlapstaggerDelays で開始をずらし、staggerDistances で後続項目の弾みを強めます。
  • Anticipationanticipate の既定 fit: "compress" は溜めを加えても総尺を変えないため、300ms の予算を守れます。
  • Exaggeration — 移動量だけを誇張して尺を変えないと、速度が上がります。誇張と尺はセットで調整します。
  • Appeal — Personality の tempo がすべてのレシピと振る舞いの尺に掛かります。