コンテンツにスキップ

派手な動き(Expressive)

twelve-principles の既定値は UI ガイドライン に沿っています。ユーザー起点の動きは 300ms 以内、スタッガーは 1 項目 50ms 以内、押下の縮みは 2〜5% です。業務画面ではこれが正解ですが、祝福の瞬間やオンボーディング、ゲーム、マーケティングページでは、控えめすぎて何も伝わらないことがあります。

このページでは、ガイドラインの外へ 意図して 踏み出すための仕組みをまとめます。どれも既定では無効で、明示的に選んだときだけ効きます。

LIVE
Expressive
上段のボタンで派手なレシピを 1 つずつ再生する。cascade は 6 個の点の時間差入場で、cap 50ms(UI)と 150ms(派手)を切り替えると 1 項目の間隔が 50ms から 120ms に広がる。右上の Personality を bouncy / cartoon にすると振れ幅が増え、「押してみる」の沈み込みも深くなる。

いつガイドラインの外へ出るか

Section titled “いつガイドラインの外へ出るか”

派手な動きは、ユーザーが 待っていない 場面、または 待つ価値がある 場面に限ります。

向いている場面
一度きりの達成・祝福 初回公開、実績解除、課金完了、連続記録の更新
システム起点の演出 オンボーディング、空状態のイラスト、読み込み完了の合図
遊ぶこと自体が目的の画面 ゲーム、子ども向けアプリ、キャンペーンページ
注意を強く引く必要がある一点 見落とされがちな新機能のバッジ(ただし一度だけ)

逆に、ホバー・押下・フォーム送信・画面遷移のように 1 日に何十回も繰り返す操作には使いません。最初の 1 回は楽しくても、10 回目には待ち時間になります。

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 ガイドラインの上限を守る
}
  • truenatural / snappy / calm / playful): インタラクションのフィードバックを UI ガイドラインの範囲に収めます。
  • falsebouncy / 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 の大きな動きはそれらの値から来ています。

派手な用途向けのプリセットが 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

definePersonalityguardrails: false を指定します。guardrailsbase から引き継がれるので、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 でなければ TypeErrorPersonality.guardrails must be a boolean, got ...)になります。ほかの数値の範囲(exaggeration は 0〜4、squash は 0〜0.5 など)は従来どおり RangeError で検証されます。

src/expressive-recipes.ts のレシピは、注意を引く動きと祝福の動きです。どれも RecipeOptionspersonality / duration / distance)を受け取り、MotionSpec を返します。

  • 尺は基準の尺 × tempoduration を指定するとその値)。
  • 振れ幅(角度・距離・拡大率・変形量)は exaggeration に比例します。
  • すべて 静止状態で終わります。終了後にインラインの transform は残りません。bounceInfallInopacity: 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
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;

skewXskewY を同じ角度で揺らし、叩かれたゼリーのように震えます。角度は [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 です。

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 です。上端から吊られているように見えるので、要素の上に余白を取ると自然です。

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 まで小さく潰れて着地します。

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 でもはっきり弾みます。

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 です。

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

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 です。

capoverlapuseCascade にもそのまま渡せます。

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 }) で明示した値はどちらの場合もクランプされません。

派手なレシピも普通の 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)。cartoonexaggeration 2.4)にさらに倍率を掛けるときは結果を確認してください。

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

useMotion().playMotion / PresenceenteruseCascade の入力には (personality) => spec を渡せるので、MotionProvider の Personality がそのままレシピに届きます。範囲を入れ子の MotionProvider で限定すれば、祝福の画面だけ派手にしてアプリ全体のガードレールは保てます。

  • 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 回転や cartoonwobble は、reduced motion を設定していないユーザーにも強い刺激です。1 画面で同時に再生するのは 1 つにします。
  • ループは止められるようにします。 iterations: Infinityheartbeatswing を回し続けると、画面に常に動くものが残ります。自動で始まり 5 秒を超えて動き続けるものには停止手段が必要です(WCAG 2.2.2)。止めるには stop(el) を呼びます。
  • 意味をモーションだけに頼りません。 「達成した」ことはテキストや aria-live でも伝えてください。

詳しくは アクセシビリティ を参照してください。