レシピと原則の合成
レシピは、よく使う UI モーションを複数の原則の組み合わせとして実装し、Personality で調整済みの MotionSpec として返す関数です。戻り値はただのデータなので、そのまま play() に渡すことも、原則関数でさらに加工することもできます。
共通オプション RecipeOptions
Section titled “共通オプション RecipeOptions”enter / exit / shake は RecipeOptions を、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) |
|---|---|---|---|---|---|---|---|
enter(pop 以外) |
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)を超えます。jump と shake はもともと注意喚起用の演出なので、この上限の外側に置かれています。詳しくは UI ガイドライン を参照してください。
enter と exit
Section titled “enter と exit”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 } |
拡大してオーバーシュートする |
zoom と pop は distance を使いません。誇張は静止値からの偏差に掛かるので、playful(exaggeration 1.4)では rise が 16.8px、pop の開始 scale が 0.79 に、calm(0.7)では rise が 8.4px、zoom が 0.965 になります。
イージングと原則
Section titled “イージングと原則”enterは最初の区間にeasings.out(cubic-bezier(0.22, 1, 0.36, 1))を使います。素早く到着し、ゆっくり落ち着く Slow out です。exitはeasings.in(cubic-bezier(0.55, 0, 1, 0.45))です。徐々に勢いをつけて去る Slow in で、入場より短い尺(fast150ms)になっています。
pop の特別扱い
Section titled “pop の特別扱い”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" |
anticipation と squash も参照する |
注意を引くための「その場ジャンプ」で、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] × d(d = 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}pressDepth と settleSpring
Section titled “pressDepth と settleSpring”どちらも MotionSpec ではなく、振る舞い(pressable など)が使う部品です。
function pressDepth(personality?: PersonalityInput): number;function settleSpring(personality?: PersonalityInput): Spring;pressDepthは押下時の縮小量です。personality.squash × 0.75を、personality.guardrailsがtrueなら 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") });}原則を合成する
Section titled “原則を合成する”原則の関数はすべて「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]! })));}合成の順序には意味があります。
anticipateとfollowThroughは内部でwithoutGlobalEasingを呼び、spec 全体のeasingをフレームごとのイージングへ移してからフレームを挿入・差し替えます。どちらを先に掛けても形は崩れませんが、followThrough(anticipate(spec))なら「溜め → 動作 → 行き過ぎ」の順になります。exaggerateは各フレームの静止値からの偏差を拡大するだけで、offsetやeasingはそのまま残します。スプリングを含む spec にも安全に掛けられます。overlapは受け取った spec をcount個に複製し、遅延と follow through の強さだけを変えます。
独自のレシピを作る
Section titled “独自のレシピを作る”レシピと同じく RecipeOptions を受け取り、resolvePersonality で個性を解決して tempo と exaggeration を反映すれば、MotionProvider や useMotion からそのまま使える関数になります。
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;}React から使う
Section titled “React から使う”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 })));import { enter, jump } from "twelve-principles";import { Presence, useMotion } from "twelve-principles/react";
export function Notice({ open }: { open: boolean }) { const { ref, play } = useMotion<HTMLDivElement>(); return ( <> {/* 文字列を渡すと enter / exit レシピが MotionProvider の Personality で呼ばれる */} <Presence show={open} enter="pop" exit="pop" role="status"> 保存しました </Presence> <div ref={ref} onClick={() => play((p) => jump({ personality: p, height: 20 }))}> ! </div> <button onClick={() => play((p) => enter("rise", { personality: p }))}>再入場</button> </> );}play に (personality) => spec を渡すと、最も近い MotionProvider の Personality でレシピが組み立てられます。詳しくは React ガイド を参照してください。