派手な動き(Expressive)
twelve-principles の既定値は UI ガイドライン に沿っています。ユーザー起点の動きは 300ms 以内、スタッガーは 1 項目 50ms 以内、押下の縮みは 2〜5% です。業務画面ではこれが正解ですが、祝福の瞬間やオンボーディング、ゲーム、マーケティングページでは、控えめすぎて何も伝わらないことがあります。
このページでは、ガイドラインの外へ 意図して 踏み出すための仕組みをまとめます。どれも既定では無効で、明示的に選んだときだけ効きます。
いつガイドラインの外へ出るか
Section titled “いつガイドラインの外へ出るか”派手な動きは、ユーザーが 待っていない 場面、または 待つ価値がある 場面に限ります。
| 向いている場面 | 例 |
|---|---|
| 一度きりの達成・祝福 | 初回公開、実績解除、課金完了、連続記録の更新 |
| システム起点の演出 | オンボーディング、空状態のイラスト、読み込み完了の合図 |
| 遊ぶこと自体が目的の画面 | ゲーム、子ども向けアプリ、キャンペーンページ |
| 注意を強く引く必要がある一点 | 見落とされがちな新機能のバッジ(ただし一度だけ) |
逆に、ホバー・押下・フォーム送信・画面遷移のように 1 日に何十回も繰り返す操作には使いません。最初の 1 回は楽しくても、10 回目には待ち時間になります。
guardrails スイッチ
Section titled “guardrails スイッチ”Personality には guardrails: boolean があります。
interface Personality { readonly name: string; readonly tempo: number; readonly exaggeration: number; readonly bounce: number; readonly anticipation: number; readonly squash: number; readonly guardrails: boolean; // true = UI ガイドラインの上限を守る}true(natural/snappy/calm/playful): インタラクションのフィードバックを UI ガイドラインの範囲に収めます。false(bouncy/cartoon): その上限を外します。
現在 guardrails を読んでいるのは pressDepth です(pressable / usePress / <Motion press> の既定の押下量)。guardrails: true では squash × 0.75 を 0.02〜0.05 に、false では 0.02〜0.2 にクランプします。尺や振れ幅は guardrails ではなく tempo / exaggeration / bounce などの数値そのもので決まるので、bouncy / cartoon の大きな動きはそれらの値から来ています。
bouncy と cartoon
Section titled “bouncy と cartoon”派手な用途向けのプリセットが 2 つあります。どちらも guardrails: false です。
| 名前 | tempo |
exaggeration |
bounce |
anticipation |
squash |
guardrails |
|---|---|---|---|---|---|---|
playful(比較用) |
1 | 1.4 | 0.45 | 0.25 | 0.08 | true |
bouncy |
0.95 | 1.7 | 0.55 | 0.3 | 0.12 | false |
cartoon |
1.15 | 2.4 | 0.65 | 0.45 | 0.22 | false |
bouncy— 大きく弾む戻りと、はっきり見える潰れ。テンポはわずかに速い。キャンペーンページ、子ども向け UI。cartoon— 深い溜め、ゴムのような潰れ、長いオーバーシュート。テンポは少し遅く、動きを見せる時間を取る。ゲーム、マスコット、祝福画面。
既存のレシピと振る舞いにこれらを渡したときの値です(ソースの式から算出)。
| 項目 | playful |
bouncy |
cartoon |
|---|---|---|---|
押下の縮小(pressDepth) / 押下中の scale |
0.05 / 0.95 | 0.09 / 0.91 | 0.165 / 0.835 |
| 押し込みの尺 | 100ms | 95ms | 115ms |
戻りのスプリング(settleSpring) |
350ms, bounce 0.45 | 333ms, bounce 0.55 | 402ms, bounce 0.65 |
| 戻りが静止するまで | 704ms | 808ms | 1167ms |
enter("rise") の尺 / 距離 |
200ms / 16.8px | 190ms / 20.4px | 230ms / 28.8px |
enter("pop") の尺 / 開始スケール / bounce |
300ms / 0.79 / 0.45 | 285ms / 0.745 / 0.55 | 345ms / 0.64 / 0.65 |
exit("pop") の溜め |
0.25 | 0.3 | 0.45 |
jump の尺 / 高さ |
450ms / 22.4px | 428ms / 27.2px | 518ms / 38.4px |
shake の尺 / 振幅 |
400ms / 8.4px | 380ms / 10.2px | 460ms / 14.4px |
独自の派手な Personality を作る
Section titled “独自の派手な Personality を作る”definePersonality で guardrails: false を指定します。guardrails は base から引き継がれるので、natural / playful などを base にした場合は明示的に false を書く必要があります。
import { definePersonality, pressDepth } from "twelve-principles";
// playful をベースに、押下だけ深くするexport const party = definePersonality( { name: "party", exaggeration: 2, squash: 0.15, guardrails: false }, "playful",);
pressDepth(party); // 0.1125(押下中の scale は約 0.89)pressDepth("playful"); // 0.05(guardrails: true なので 0.05 で頭打ち)guardrails が boolean でなければ TypeError(Personality.guardrails must be a boolean, got ...)になります。ほかの数値の範囲(exaggeration は 0〜4、squash は 0〜0.5 など)は従来どおり RangeError で検証されます。
派手なレシピ
Section titled “派手なレシピ”src/expressive-recipes.ts のレシピは、注意を引く動きと祝福の動きです。どれも RecipeOptions(personality / duration / distance)を受け取り、MotionSpec を返します。
- 尺は基準の尺 ×
tempo(durationを指定するとその値)。 - 振れ幅(角度・距離・拡大率・変形量)は
exaggerationに比例します。 - すべて 静止状態で終わります。終了後にインラインの
transformは残りません。bounceInとfallInはopacity: 0から始まる入場です。
| レシピ | 基準の尺 | 組み合わせている原則 | 向いている場面 |
|---|---|---|---|
rubberBand |
800ms | Squash & Stretch、Follow through | 成功したボタンやロゴを「ビヨン」と弾ませる |
jello |
900ms | Follow through、Exaggeration | タップされたマスコットやイラストの反応 |
tada |
1000ms | Anticipation、Secondary action、Exaggeration | 実績解除、達成のバッジ |
heartbeat |
1300ms | Timing、Slow in & slow out | いいね、お気に入りへの追加 |
swing |
1000ms | Arcs、Follow through | 吊り下がった看板や通知ベルの揺れ |
wobble |
1000ms | Follow through、Exaggeration | 「ここを見て」と横に揺らして注意を引く |
flip |
1000ms | Solid drawing、Squash & Stretch、Slow in & slow out | カードをめくる、バッジを裏返して見せる |
bounceIn |
スプリングの静定時間 | Follow through、Exaggeration | モーダルやバッジの派手な登場 |
fallIn |
落下の物理時間 × tempo |
Straight ahead、Squash & Stretch、Timing | 上から落ちてきて弾む登場 |
Personality ごとの実際の尺(ms)です。
| レシピ | natural |
snappy |
calm |
playful |
bouncy |
cartoon |
|---|---|---|---|---|---|---|
rubberBand |
800 | 640 | 1000 | 800 | 760 | 920 |
jello |
900 | 720 | 1125 | 900 | 855 | 1035 |
tada |
1000 | 800 | 1250 | 1000 | 950 | 1150 |
heartbeat |
1300 | 1040 | 1625 | 1300 | 1235 | 1495 |
swing / wobble / flip |
1000 | 800 | 1250 | 1000 | 950 | 1150 |
bounceIn |
912 | 729 | 1142 | 912 | 1037 | 1504 |
fallIn |
617 | 468 | 645 | 730 | 764 | 1099 |
rubberBand
Section titled “rubberBand”function rubberBand(options?: RecipeOptions & { axis?: Axis }): MotionSpec; // axis 既定 "x"体積を保つ deform で伸び縮みを繰り返し、減衰しながら静止します。変形量は [0, 1, -0.8, 0.5, -0.2, 0.1, 0] × 0.25 × exaggeration で、要素が反転しないよう -0.9 より小さくはなりません。natural では最初に横へ 25% 伸び、cartoon では 60% 伸びます。axis: "y" で縦方向に伸びます。
function jello(options?: RecipeOptions): MotionSpec;skewX と skewY を同じ角度で揺らし、叩かれたゼリーのように震えます。角度は [0, -1, 0.5, -0.25, 0.125, -0.0625, 0] × 12.5 × exaggeration(deg)で、毎回半分に減衰します。
function tada(options?: RecipeOptions): MotionSpec;最初の 20% で縮んで逆に傾き(溜め)、30〜90% で膨らんだまま左右に小刻みに揺れ(勝利の身振り)、最後に静止します。縮み・膨らみは 0.1 × exaggeration、傾きは 3 × exaggeration deg です。
heartbeat
Section titled “heartbeat”function heartbeat(options?: RecipeOptions): MotionSpec;14% と 42% の 2 か所で 1 + 0.3 × exaggeration まで膨らみ、70% 以降は静止して間を置きます。形は単純な拡大だけで、感情は「ドクン、ドクン、休み」というタイミングが運びます。
function swing(options?: RecipeOptions): MotionSpec;transform-origin を "50% 0%"(上端の中央)にして振り子のように回転します。角度は [0, 1, -0.66, 0.33, -0.33, 0] × 15 × exaggeration deg です。上端から吊られているように見えるので、要素の上に余白を取ると自然です。
wobble
Section titled “wobble”function wobble(options?: RecipeOptions): MotionSpec;横移動と回転を同じ比率 [0, -1, 0.8, -0.6, 0.4, -0.2, 0] で減衰させ、よろめくように揺れます。距離は distance(既定 25px)× exaggeration、角度は 5 × exaggeration deg です。shake より大きく、回転を伴います。
function flip(options?: RecipeOptions): MotionSpec;perspective: 400 の実際の 3D 空間で Y 軸まわりに 1 回転します。回転は rotateY: -360 から 0 へ向かうので、見た目は 0 → 360 と同じですが静止状態で終わり、回転がインラインに残りません。途中で z 方向(手前)へ 150 × min(exaggeration, 2) px 浮き、80% の時点で 1 - 0.05 × exaggeration まで小さく潰れて着地します。
bounceIn
Section titled “bounceIn”function bounceIn(options?: RecipeOptions): MotionSpec;opacity: 0、scale max(1 - 0.5 × exaggeration, 0.05) から、ゆるいスプリングで scale 1 へ到達します。スプリングは spring({ duration: 450 × tempo, bounce: max(personality.bounce, 0.5) }) で、尺はその静定時間です(natural で 912ms)。enter("pop") の最低 bounce が 0.25 なのに対し、こちらは最低 0.5 なので、どの Personality でもはっきり弾みます。
fallIn
Section titled “fallIn”function fallIn(options?: FallInOptions): MotionSpec;
interface FallInOptions extends RecipeOptions { height?: number; // 落下の高さ(px)。exaggeration が掛かる。既定 80}重力(2600 px/s²)で落下し、着地のたびに速度の 45% を残して 3 回跳ね返る様子を、straightAhead で 1 コマずつシミュレーションします。落下中は速度に応じて縦に伸び、着地の瞬間に潰れます(変形量は squash に比例し、最大 0.35)。transform-origin は "50% 100%"、opacity は最初の 1/8 でフェードインします。尺は物理的な所要時間 × tempo で、natural(高さ 80px)では 617ms です。
expressive と ExpressiveRecipe
Section titled “expressive と ExpressiveRecipe”type ExpressiveRecipe = | "rubberBand" | "jello" | "tada" | "heartbeat" | "swing" | "wobble" | "flip" | "bounceIn" | "fallIn";
const expressive: Record<ExpressiveRecipe, (options?: RecipeOptions) => MotionSpec>;名前からレシピを引くための辞書です。設定画面のピッカーや、CMS から演出名を受け取る場合に使えます。
import { expressive, play, type ExpressiveRecipe } from "twelve-principles";
export function celebrate(el: HTMLElement, name: ExpressiveRecipe) { play(el, expressive[name]({ personality: "bouncy" }));}スタッガーの cap
Section titled “スタッガーの cap”StaggerOptions.cap は 1 項目あたりの遅延の上限です。既定は MAX_STAGGER_MS(50ms)で、これが UI ガイドラインの値です。見せるためのカスケードでは cap を上げます。
import { staggerDelays } from "twelve-principles";
staggerDelays(6, { each: 120 }); // [0, 50, 100, 150, 200, 250] (cap 50 で頭打ち)staggerDelays(6, { each: 120, cap: 150, total: 900 }); // [0, 120, 240, 360, 480, 600]staggerDelays(6, { each: 120, cap: 150 }); // [0, 60, 120, 180, 240, 300] (total 300 で頭打ち)1 ステップの遅延は min(each, cap, total / 起点からの最大距離) です。cap を上げても total(既定 300ms)が残っていると、項目数によってはそちらで詰められます。ゆったりしたカスケードにしたいときは total も一緒に上げてください。cap が負(または NaN)なら RangeError です。
cap は overlap と useCascade にもそのまま渡せます。
import { bounceIn, overlap, playAll } from "twelve-principles";
export async function revealBadges(badges: HTMLElement[]) { const specs = overlap(bounceIn({ personality: "cartoon" }), badges.length, { each: 120, cap: 150, total: 900, drag: 0.15, }); await playAll(badges.map((element, i) => ({ element, spec: specs[i]! })));}pressDepth(personality) は min(max(squash × 0.75, 0.02), guardrails ? 0.05 : 0.2) です。
| Personality | pressDepth |
押下中の scale |
|---|---|---|
natural |
0.03 | 0.97 |
snappy |
0.0225 | 0.9775 |
calm |
0.02 | 0.98 |
playful |
0.05(上限) | 0.95 |
bouncy |
0.09 | 0.91 |
cartoon |
0.165 | 0.835 |
pressable / usePress / <Motion press> の既定値はこの関数なので、Personality を切り替えるだけで押下が深くなります。guardrails: false でも上限は 0.2(scale 0.8)で、squash が約 0.267(0.2 ÷ 0.75)を超えるとそこで頭打ちになります。pressable(el, { depth }) で明示した値はどちらの場合もクランプされません。
原則と合成する
Section titled “原則と合成する”派手なレシピも普通の MotionSpec なので、原則の関数で加工できます。
import { accompany, exaggerate, followThrough, heartbeat, play, rubberBand, swing, tada,} from "twelve-principles";
// tada をさらに 1.5 倍に誇張(尺は同じなので速さも 1.5 倍)const bigTada = exaggerate(tada(), 1.5);
// 看板の揺れの最後をスプリングにして、さらに長く余韻を残すconst looseSwing = followThrough(swing({ personality: "bouncy" }), { bounce: 0.5 });
// 3 回だけ鼓動させるconst threeBeats = { ...heartbeat(), iterations: 3 };
// ボタンが伸び縮みし、中のアイコンが少し遅れて揺れるfunction celebrate(button: HTMLElement, icon: HTMLElement) { const main = rubberBand({ personality: "cartoon" }); play(button, main); play(icon, accompany(main, "wiggle"));}exaggerate は静止値からの偏差を拡大するだけなので、大きな倍率では scale が 0 を下回ることがあります(Exaggeration)。cartoon(exaggeration 2.4)にさらに倍率を掛けるときは結果を確認してください。
React から使う
Section titled “React から使う”import { bounceIn, rubberBand, tada } from "twelve-principles";import { Motion, MotionProvider, useMotion } from "twelve-principles/react";
export function Victory() { return ( // この画面だけ cartoon にする。外側のアプリは natural のまま <MotionProvider personality="cartoon"> <Motion as="section" enter={(p) => bounceIn({ personality: p })}> <Trophy /> {/* guardrails: false なので押下は scale 0.835 まで沈む */} <Motion as="button" type="button" press hover> もう一度遊ぶ </Motion> </Motion> </MotionProvider> );}
function Trophy() { const { ref, play } = useMotion<HTMLDivElement>(); return ( <div ref={ref} onClick={() => play((p) => rubberBand({ personality: p }))} onDoubleClick={() => play((p) => tada({ personality: p }))} > トロフィー </div> );}import { bounceIn, play, pressable, rubberBand } from "twelve-principles";
const trophy = document.querySelector<HTMLElement>(".trophy")!;const retry = document.querySelector<HTMLButtonElement>(".retry")!;
play(trophy, bounceIn({ personality: "cartoon" }));trophy.addEventListener("click", () => play(trophy, rubberBand({ personality: "cartoon" })));pressable(retry, { personality: "cartoon" }); // pressDepth 0.165useMotion().play、Motion / Presence の enter、useCascade の入力には (personality) => spec を渡せるので、MotionProvider の Personality がそのままレシピに届きます。範囲を入れ子の MotionProvider で限定すれば、祝福の画面だけ派手にしてアプリ全体のガードレールは保てます。
アクセシビリティ
Section titled “アクセシビリティ”- reduced motion はそのまま効きます。 既定の
reducedMotion: "auto"では、prefers-reduced-motion: reduceのとき spec は終端へのフェード(最大 150ms)に置き換わります。rubberBand/jello/tada/heartbeat/swing/wobble/flipは静止状態で始まり静止状態で終わるので何も動かず、bounceIn/fallInは opacity 0 → 1 のフェードだけになります。これらにreducedMotion: "full"を指定しないでください。 - 大きな回転と移動は前庭障害の引き金になりえます。
flipの 3D 回転やcartoonのwobbleは、reduced motion を設定していないユーザーにも強い刺激です。1 画面で同時に再生するのは 1 つにします。 - ループは止められるようにします。
iterations: Infinityでheartbeatやswingを回し続けると、画面に常に動くものが残ります。自動で始まり 5 秒を超えて動き続けるものには停止手段が必要です(WCAG 2.2.2)。止めるにはstop(el)を呼びます。 - 意味をモーションだけに頼りません。 「達成した」ことはテキストや
aria-liveでも伝えてください。
詳しくは アクセシビリティ を参照してください。