コンテンツにスキップ

レシピと原則の合成

レシピは、よく使う UI モーションを複数の原則の組み合わせとして実装し、Personality で調整済みの MotionSpec として返す関数です。戻り値はただのデータなので、そのまま play() に渡すことも、原則関数でさらに加工することもできます。

LIVE
Recipe
セレクトで TransitionKind を選び、enter() / exit() / jump() / shake() を押す。Personality を切り替えると尺と振れ幅が変わる。

enter / exit / shakeRecipeOptions を、jump はそれを拡張した JumpOptions を受け取ります。

interface RecipeOptions {
personality?: PersonalityInput;
duration?: number;
distance?: number;
}
オプション 既定値 説明
personality PersonalityInput "natural" テンポ・誇張・弾性の出どころ。名前("natural" / "snappy" / "calm" / "playful" / "bouncy" / "cartoon")か Personality オブジェクト
duration number レシピごと(下表) 尺(ms)。指定すると Personality の tempo による伸縮を無視してこの値を使う
distance number enter / exit は 12、shake は 6 移動距離(px)。Personality の exaggeration が掛かる

レシピ内部では、距離・角度・拡大率に exaggeration を、尺に tempo を掛けています。opacity は誇張の対象外です(exaggerate の既定キーが opacity を除くため)。

duration を指定しなかった場合の実際の尺(ms)です。tempo を掛けた値は Math.round で丸められます。

レシピ 基準 natural(1) snappy(0.8) calm(1.25) playful(1) bouncy(0.95) cartoon(1.15)
enterpop 以外) durations.base 200 200 160 250 200 190 230
enter("pop") durations.slow 300 300 240 375 300 285 345
exit durations.fast 150 150 120 188 150 143 173
jump 450 450 360 563 450 428 518
shake 400 400 320 500 400 380 460

calm(375ms)と cartoon(345ms)では enter("pop") がユーザー起点の上限 300ms(USER_INITIATED_MAX_MS)を超えます。jumpshake はもともと注意喚起用の演出なので、この上限の外側に置かれています。詳しくは UI ガイドライン を参照してください。

function enter(kind?: TransitionKind, options?: RecipeOptions): MotionSpec; // kind 既定 "rise"
function exit(kind?: TransitionKind, options?: RecipeOptions): MotionSpec; // kind 既定 "fade"
type TransitionKind = "fade" | "rise" | "drop" | "slideLeft" | "slideRight" | "zoom" | "pop";

どちらも「隠れたポーズ」と静止状態の 2 フレームだけで構成されます。enter は隠れたポーズから静止状態へ、exit は静止状態から同じ隠れたポーズへ向かいます。静止側のフレームは隠れたポーズが使うキーだけに絞られるため、両端の transform の構造が揃い、補間が崩れません。

kind 隠れたポーズ(distance = 12、natural 見え方
fade { opacity: 0 } その場でフェード
rise { opacity: 0, y: 12 } 下から浮き上がる
drop { opacity: 0, y: -12 } 上から降りてくる
slideLeft { opacity: 0, x: 12 } 右側から左へ入る
slideRight { opacity: 0, x: -12 } 左側から右へ入る
zoom { opacity: 0, scale: 0.95 } わずかに拡大しながら現れる
pop { opacity: 0, scale: 0.85 } 拡大してオーバーシュートする

zoompopdistance を使いません。誇張は静止値からの偏差に掛かるので、playfulexaggeration 1.4)では rise が 16.8px、pop の開始 scale が 0.79 に、calm(0.7)では rise が 8.4px、zoom が 0.965 になります。

  • enter は最初の区間に easings.outcubic-bezier(0.22, 1, 0.36, 1))を使います。素早く到着し、ゆっくり落ち着く Slow out です。
  • exiteasings.incubic-bezier(0.55, 0, 1, 0.45))です。徐々に勢いをつけて去る Slow in で、入場より短い尺(fast 150ms)になっています。

pop だけは別の原則が追加されます。

  • enter("pop"): 尺が durations.slow(300ms × tempo)になり、followThrough(spec, { bounce: Math.max(personality.bounce, 0.25) }) が掛かります。最終区間がスプリングで駆動され、scale 1 を行き過ぎてから戻ります。bounce は最低 0.25 なので、calm(bounce 0)でもわずかに弾みます。スプリングは JS 関数なので、300ms なら 60fps 相当の 19 枚のキーフレームに焼き込まれます。
  • exit("pop"): Personality の anticipation が 0 より大きいとき、anticipate(spec, { amount: personality.anticipation, share: 0.35 }) が掛かります。タイムラインの最初の 35% で一度膨らみ(natural なら scale 1.0225)、そこから ease-in で 0.85 まで縮んで消えます。calm(anticipation 0)では溜めは入りません。溜めの間 opacity は 1 のままです(windUp は opacity を引き戻さない)。
import { enter, exit, play } from "twelve-principles";
async function toggleDialog(dialog: HTMLElement, open: boolean) {
if (open) {
dialog.hidden = false;
play(dialog, enter("pop", { personality: "playful" }));
return;
}
await play(dialog, exit("pop", { personality: "playful" })).finished.catch(() => undefined);
dialog.hidden = true;
}

exit の終端(opacity 0)は静止ポーズではないので、終了後はインラインスタイルに固定されます。次に enter を再生すると静止状態で終わるため、所有しているインラインスタイルは再び取り除かれます(API リファレンスの実行時の振る舞い)。

function jump(options?: JumpOptions): MotionSpec;
interface JumpOptions extends RecipeOptions {
height?: number;
}
オプション 既定値 説明
height number 16 ジャンプの高さ(px)。exaggeration が掛かる
duration number 450 × tempo 尺(ms)
personality PersonalityInput "natural" anticipationsquash も参照する

注意を引くための「その場ジャンプ」で、5 つの原則が 1 本の poseToPose に入っています。transform-origin"50% 100%"(足元)です。変形量は s = max(personality.squash, 0.02) × 2 で決まります。

offset ポーズ 原則
0 静止
0.15(anticipation が 0 なら 0.02) しゃがむ: squash(s × (0.5 + anticipation)) Anticipation
0.45 高さ -h で縦に伸びる: stretch(s × 0.6) Squash & Stretch
0.55 高さ -h のまま変形なし(頂点で滞空) Slow in & slow out
0.8 着地して潰れる: squash(s) Squash & Stretch
1 静止 Follow through

natural では高さ 16px、しゃがみで scaleY 0.948、離陸で 1.048、着地で 0.92 になります。上昇区間は easings.out、頂点からの落下は easings.in で、重力による加減速を表します。

function shake(options?: RecipeOptions): MotionSpec;

入力エラーなどを知らせる x 方向の減衰振動です。キーは [0, -1, 0.8, -0.6, 0.4, -0.2, 0] × dd = distance × exaggeration、既定 6px)で、各区間は easings.inOut、尺は 400ms × tempo です。振れ幅が毎回小さくなることで、エネルギーが抜けていく Follow through として読めます。

import { play, shake } from "twelve-principles";
function rejectInput(field: HTMLInputElement) {
field.setAttribute("aria-invalid", "true");
play(field, shake({ personality: "snappy" })); // 320ms、振れ幅 5.4px
}

どちらも MotionSpec ではなく、振る舞い(pressable など)が使う部品です。

function pressDepth(personality?: PersonalityInput): number;
function settleSpring(personality?: PersonalityInput): Spring;
  • pressDepth は押下時の縮小量です。personality.squash × 0.75 を、personality.guardrailstrue なら 0.02〜0.05、false なら 0.02〜0.2 にクランプします。ガードレールのある Personality(natural / snappy / calm / playful)では押下中の scale が 0.95〜0.98 に収まり、bouncy / cartoon ではそれより深く沈みます。
  • settleSpring は押下やホバー、チルトを解除するときのスプリングで、spring({ duration: 350 × tempo, bounce: personality.bounce }) です。戻り値の .duration は静定時間(目標の 0.1% 以内に収まるまでの ms)で、animateTo はこれをそのまま尺として使います。
Personality pressDepth 押下中の scale settleSpring().duration
natural 0.03 0.97 475
snappy 0.0225 0.9775 375
calm 0.02(クランプ) 0.98 646
playful 0.05(クランプ) 0.95 704
bouncy 0.09 0.91 808
cartoon 0.165 0.835 1167
import { setLayer, settleSpring } from "twelve-principles";
// 独自のドラッグ操作で、離したときだけ Personality のバネで戻す
function onDragEnd(el: HTMLElement) {
setLayer(el, "drag", null, { transition: settleSpring("playful") });
}

原則の関数はすべて「MotionSpec を受け取って MotionSpec を返す」か「Pose を返す」形をしているので、関数合成でつなげられます。レシピの戻り値も同じ MotionSpec なので、原則でさらに加工できます。

import {
accompany,
anticipate,
arc,
enter,
exaggerate,
followThrough,
jump,
overlap,
play,
playAll,
poseToPose,
} from "twelve-principles";
// 溜めてから動き、行き過ぎて落ち着く(Anticipation + Follow through)
const toss = followThrough(anticipate(poseToPose([{ x: 0 }, { x: 200 }])), { bounce: 0.4 });
// jump を 1.5 倍に誇張: 高さ 24px、しゃがみ・伸び・潰れも 1.5 倍
const bigJump = exaggerate(jump(), 1.5);
// 弧を描く移動を誇張(REST 基準なので移動距離そのものも 1.3 倍)
const swoop = exaggerate(arc({ x: 0, y: 0 }, { x: 200, y: 0 }), 1.3);
// 主動作に合わせてアイコンが遅れて揺れる(Secondary action)
function celebrate(button: HTMLElement, icon: HTMLElement) {
const main = jump();
play(button, main);
play(icon, accompany(main, "wiggle"));
}
// リストの項目を時間差で入場させる(Overlapping action)
async function revealList(list: HTMLElement) {
const items = Array.from(list.children) as HTMLElement[];
const specs = overlap(enter("rise"), items.length, { each: 40 });
await playAll(items.map((element, i) => ({ element, spec: specs[i]! })));
}

合成の順序には意味があります。

  • anticipatefollowThrough は内部で withoutGlobalEasing を呼び、spec 全体の easing をフレームごとのイージングへ移してからフレームを挿入・差し替えます。どちらを先に掛けても形は崩れませんが、followThrough(anticipate(spec)) なら「溜め → 動作 → 行き過ぎ」の順になります。
  • exaggerate は各フレームの静止値からの偏差を拡大するだけで、offseteasing はそのまま残します。スプリングを含む spec にも安全に掛けられます。
  • overlap は受け取った spec を count 個に複製し、遅延と follow through の強さだけを変えます。

レシピと同じく RecipeOptions を受け取り、resolvePersonality で個性を解決して tempoexaggeration を反映すれば、MotionProvideruseMotion からそのまま使える関数になります。

import {
anticipate,
duration,
poseToPose,
resolvePersonality,
type MotionSpec,
type RecipeOptions,
} from "twelve-principles";
/** 横へ飛んで消える。Personality に溜めがあれば一度逆へ引いてから飛ぶ。 */
export function flyOut(options: RecipeOptions = {}): MotionSpec {
const p = resolvePersonality(options.personality);
const d = (options.distance ?? 240) * p.exaggeration;
const base = poseToPose([{ x: 0 }, { x: d, opacity: 0 }], {
duration: options.duration ?? duration("slow", p.tempo),
});
return p.anticipation > 0 ? anticipate(base, { amount: p.anticipation }) : base;
}
import { enter, exit, jump, play } from "twelve-principles";
const chip = document.querySelector<HTMLElement>(".chip")!;
document.querySelector("#show")!.addEventListener("click", () => play(chip, enter("pop")));
document.querySelector("#hide")!.addEventListener("click", () => play(chip, exit("pop")));
document.querySelector("#jump")!.addEventListener("click", () => play(chip, jump({ height: 20 })));