Appeal(魅力)
魅力は個々の動きの出来よりも、すべての動きに一貫した性格があることから生まれます。本ライブラリでは Personality という一つのオブジェクトがテンポ・誇張・弾性・溜め・潰れを決め、すべてのレシピと振る舞いがそれを読みます。
アニメーションにおける原則
Section titled “アニメーションにおける原則”Appeal は、観客がそのキャラクターを見ていたいと感じる魅力のことです。「かわいい」ことと同じではありません。悪役にも脇役にも魅力は必要で、観客が性格を読み取れ、次に何をするかに興味を持てることが条件です。
魅力はデザインだけでなく、表情・癖・動き方から生まれます。歩き方一つでも、せっかちな人物は小刻みに速く、のんびりした人物は大きくゆったりと歩きます。形が明快で読みやすいことも重要で、複雑すぎるデザインや対称すぎるポーズは魅力を損ないます。
そして、性格は一貫していなければなりません。場面ごとに動き方が変わるキャラクターは、同じ人物に見えなくなります。
UI への翻訳
Section titled “UI への翻訳”プロダクトの UI も、ユーザーにとっては一つのキャラクターです。ボタンはきびきび沈むのにモーダルはふわふわ弾む、という状態は、場面ごとに性格が変わるキャラクターと同じで、作り込みが足りない印象を与えます。
Personality は、他の原則の強さを決める 5 つの値と、UI ガイドラインの上限を守るかどうかを決める guardrails をまとめたものです。プロダクトで一度だけ決め、すべての動きに渡します。
| フィールド | 対応する原則 | 使われる場所 |
|---|---|---|
tempo |
Timing | すべてのレシピ・振る舞い・stage の尺に掛かる倍率 |
exaggeration |
Exaggeration | enter / exit の移動距離とスケール、jump の高さ、shake の振幅、hoverable の浮上量、tiltable の角度、派手なレシピ の振れ幅 |
bounce |
Follow Through | settleSpring(pressable と tiltable の戻り)、enter("pop") のオーバーシュート(max(bounce, 0.25))、bounceIn のスプリング(max(bounce, 0.5)) |
anticipation |
Anticipation | exit("pop") の溜めの大きさ(0 なら溜めなし)、jump のしゃがみの深さとタイミング |
squash |
Squash & Stretch | jump の潰れと伸び(max(squash, 0.02) * 2)、pressDepth(squash * 0.75 をクランプ)、fallIn の着地の潰れ |
guardrails |
UI ガイドライン | pressDepth の上限。true なら 0.05(scale 0.95 まで)、false なら 0.2(scale 0.8 まで) |
6 つのプリセットの選び方の目安です。上の 4 つは guardrails: true、下の 2 つは guardrails: false です。
natural(既定) — 汎用。迷ったらこれ。snappy— 業務ツール、エディタ、操作回数の多い画面。速く、弾みは小さい。calm— 金融・医療・読み物など、落ち着きと信頼を優先する画面。弾みも溜めもない。playful— コンシューマー向け、子ども向け、ゲーム的な体験。大きく弾み、溜めも深い。ただし押下などのフィードバックはガイドライン内に収まる。bouncy— キャンペーンページや祝福の場面。playfulよりさらに大きく弾み、押下もはっきり潰れる。cartoon— ゲーム、マスコット、一度きりの演出。深い溜め、ゴムのような潰れ、長いオーバーシュート。
bouncy と cartoon は UI ガイドラインの外へ出るためのプリセットです。アプリ全体ではなく、入れ子の MotionProvider で範囲を限定して使います(派手な動き)。
各プリセットを適用したときの実際の値は次のとおりです(すべてソースの式から算出)。
| 項目 | natural |
snappy |
calm |
playful |
bouncy |
cartoon |
|---|---|---|---|---|---|---|
押下の縮小(pressDepth) |
0.03 | 0.0225 | 0.02 | 0.05 | 0.09 | 0.165 |
| 押し込みの尺 | 100ms | 80ms | 125ms | 100ms | 95ms | 115ms |
enter("rise") の尺 / 距離 |
200ms / 12px | 160ms / 10.8px | 250ms / 8.4px | 200ms / 16.8px | 190ms / 20.4px | 230ms / 28.8px |
enter("pop") の尺 / 開始スケール |
300ms / 0.85 | 240ms / 0.865 | 375ms / 0.895 | 300ms / 0.79 | 285ms / 0.745 | 345ms / 0.64 |
enter("pop") の bounce |
0.25 | 0.25 | 0.25 | 0.45 | 0.55 | 0.65 |
exit の尺 |
150ms | 120ms | 188ms | 150ms | 143ms | 173ms |
exit("pop") の溜め |
0.15 | 0.08 | なし | 0.25 | 0.3 | 0.45 |
jump の尺 / 高さ |
450ms / 16px | 360ms / 14.4px | 563ms / 11.2px | 450ms / 22.4px | 428ms / 27.2px | 518ms / 38.4px |
shake の尺 / 振幅 |
400ms / 6px | 320ms / 5.4px | 500ms / 4.2px | 400ms / 8.4px | 380ms / 10.2px | 460ms / 14.4px |
戻りのスプリング(settleSpring) |
350ms, bounce 0.2 | 280ms, bounce 0.1 | 438ms, bounce 0 | 350ms, bounce 0.45 | 333ms, bounce 0.55 | 402ms, bounce 0.65 |
| 戻りが静止するまで | 475ms | 375ms | 646ms | 704ms | 808ms | 1167ms |
settleSpring の 350ms × tempo はスプリングの知覚上の尺で、振動が 0.1% 以内に収まるまでの時間(最後の行)はそれより長くなります。playful 以降は弾みが大きいぶん静止まで長くかかり、cartoon では 1 秒を超えます。
Personality
Section titled “Personality”interface Personality { readonly name: string; readonly tempo: number; // 尺の倍率。1 未満で速く、1 より大きいと遅く readonly exaggeration: number; // 距離・角度・スケールの倍率 readonly bounce: number; // 戻りのスプリングの弾み 0〜0.95 readonly anticipation: number; // 溜めの大きさ(主動作に対する割合)0〜1 readonly squash: number; // 潰れと伸びの強さ(0.05 = ±5%) readonly guardrails: boolean; // true = 押下などを UI ガイドライン内に収める。false = 上限を外す}
type PersonalityName = "natural" | "snappy" | "calm" | "playful" | "bouncy" | "cartoon";type PersonalityInput = PersonalityName | Personality;personalities
Section titled “personalities”const personalities = { natural: { name: "natural", tempo: 1, exaggeration: 1, bounce: 0.2, anticipation: 0.15, squash: 0.04, guardrails: true }, snappy: { name: "snappy", tempo: 0.8, exaggeration: 0.9, bounce: 0.1, anticipation: 0.08, squash: 0.03, guardrails: true }, calm: { name: "calm", tempo: 1.25, exaggeration: 0.7, bounce: 0, anticipation: 0, squash: 0.02, guardrails: true }, playful: { name: "playful", tempo: 1, exaggeration: 1.4, bounce: 0.45, anticipation: 0.25, squash: 0.08, guardrails: true }, bouncy: { name: "bouncy", tempo: 0.95, exaggeration: 1.7, bounce: 0.55, anticipation: 0.3, squash: 0.12, guardrails: false }, cartoon: { name: "cartoon", tempo: 1.15, exaggeration: 2.4, bounce: 0.65, anticipation: 0.45, squash: 0.22, guardrails: false },} as const satisfies Record<string, Personality>;| 名前 | tempo |
exaggeration |
bounce |
anticipation |
squash |
guardrails |
|---|---|---|---|---|---|---|
natural |
1 | 1 | 0.2 | 0.15 | 0.04 | true |
snappy |
0.8 | 0.9 | 0.1 | 0.08 | 0.03 | true |
calm |
1.25 | 0.7 | 0 | 0 | 0.02 | true |
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 |
definePersonality
Section titled “definePersonality”function definePersonality(overrides: Partial<Personality>, base?: PersonalityInput): Personality; // base 既定 "natural"base を解決し、overrides を上書きしてから各値を検証し、Object.freeze した Personality を返します。name と guardrails を指定しなければ base の値を引き継ぎます。
| フィールド | 許容範囲 |
|---|---|
tempo |
0.1〜4 |
exaggeration |
0〜4 |
bounce |
0〜0.95 |
anticipation |
0〜1 |
squash |
0〜0.5 |
guardrails |
true / false |
数値の範囲外や数値でない値(NaN を含む)は RangeError、guardrails が boolean でなければ TypeError になります。
const brand = definePersonality({ name: "brand", tempo: 0.9, bounce: 0.3 }, "snappy");// { name: "brand", tempo: 0.9, exaggeration: 0.9, bounce: 0.3, anticipation: 0.08, squash: 0.03, guardrails: true }
// playful をベースにガードレールだけ外す: pressDepth は 0.05 → 0.06const party = definePersonality({ name: "party", guardrails: false }, "playful");
definePersonality({ bounce: 1 });// RangeError: Personality.bounce must be within [0, 0.95], got 1resolvePersonality
Section titled “resolvePersonality”function resolvePersonality(input?: PersonalityInput): Personality; // input 既定 "natural"プリセット名なら personalities の値を返し、未知の名前なら RangeError(Unknown personality "...")を投げます。オブジェクトはそのまま返し、検証しません。レシピと振る舞いはすべて、受け取った personality オプションをこの関数で解決します。
personality を受け取る API
Section titled “personality を受け取る API”| API | 受け取り方 |
|---|---|
enter / exit / jump / shake |
options.personality |
rubberBand / jello / tada / heartbeat / swing / wobble / flip / bounceIn / fallIn |
options.personality |
pressDepth / settleSpring |
第 1 引数 |
pressable / hoverable / tiltable |
options.personality |
stage |
options.personality(尺にだけ影響) |
React のフックと Motion / Presence |
MotionProvider から自動で受け取る |
省略するとすべて "natural" として扱われます。
React: MotionProvider と useMotionConfig
Section titled “React: MotionProvider と useMotionConfig”function MotionProvider({ personality = "natural", reducedMotion = "auto", children }: MotionProviderProps);function useMotionConfig(): MotionConfig; // { personality: Personality; reducedMotion: ReducedMotion }| プロパティ | 型 | 既定値 | 説明 |
|---|---|---|---|
personality |
PersonalityInput |
"natural" |
サブツリー全体の Personality |
reducedMotion |
ReducedMotion |
"auto" |
サブツリー全体の reduced motion の扱い |
children |
ReactNode |
なし |
MotionProvider の外では、useMotionConfig() は personalities.natural と "auto" を返します。MotionProvider は入れ子にでき、内側の値が優先されます。usePress / useHover / useTilt と Motion の press / hover / tilt では、オプションに personality を書くとその要素だけ上書きできます。
useMotion().play、useEnter、usePresence、useCascade は、spec の代わりに (personality) => spec を受け取れます。関数はプロバイダの Personality で呼ばれます。
ブランドの Personality を一か所で定義する
Section titled “ブランドの Personality を一か所で定義する”// motion.ts — プロダクトで唯一の定義import { definePersonality } from "twelve-principles";
export const brand = definePersonality({ name: "brand", tempo: 0.9, bounce: 0.3 }, "snappy");import { enter, exit, hoverable, play, pressable } from "twelve-principles";import { brand } from "./motion";
export function setupSaveButton(button: HTMLButtonElement, toast: HTMLElement): void { pressable(button, { personality: brand }); hoverable(button, { personality: brand });
button.addEventListener("click", async () => { toast.hidden = false; play(toast, enter("pop", { personality: brand })); await new Promise((resolve) => setTimeout(resolve, 2000)); await play(toast, exit("pop", { personality: brand })).finished; toast.hidden = true; });}import { useState } from "react";import { definePersonality } from "twelve-principles";import { Motion, MotionProvider, Presence } from "twelve-principles/react";
// コンポーネントの外で一度だけ作るconst brand = definePersonality({ name: "brand", tempo: 0.9, bounce: 0.3 }, "snappy");
export function App() { return ( <MotionProvider personality={brand}> <SaveButton /> </MotionProvider> );}
function SaveButton() { const [saved, setSaved] = useState(false); return ( <> <Motion as="button" type="button" press hover onClick={() => setSaved((s) => !s)}> {saved ? "閉じる" : "保存する"} </Motion> <Presence show={saved} enter="pop" exit="pop" role="status"> 保存しました </Presence> </> );}ユーザー設定で性格を切り替える
Section titled “ユーザー設定で性格を切り替える”「動きを控えめにする」設定を用意する場合、プリセット名を状態として持てば Personality オブジェクトの同一性が保たれます。
import { jump, play, type PersonalityName } from "twelve-principles";
let personality: PersonalityName = localStorage.getItem("motion") === "calm" ? "calm" : "natural";
export function setCalm(calm: boolean): void { personality = calm ? "calm" : "natural"; localStorage.setItem("motion", personality);}
export function notify(badge: HTMLElement): void { play(badge, jump({ personality }));}import { useState, type ReactNode } from "react";import { MotionProvider } from "twelve-principles/react";
export function MotionSettings({ children }: { children: ReactNode }) { const [calm, setCalm] = useState(false); return ( <MotionProvider personality={calm ? "calm" : "natural"}> <label> <input type="checkbox" checked={calm} onChange={(e) => setCalm(e.target.checked)} /> 動きを控えめにする </label> {children} </MotionProvider> );}一部だけ性格を変える
Section titled “一部だけ性格を変える”祝福の画面だけ playful にするなど、意図した例外は入れ子の MotionProvider で範囲を限定します。
import { MotionProvider, Motion } from "twelve-principles/react";
export function Celebration() { return ( <MotionProvider personality="playful"> <Motion as="section" enter="pop"> はじめてのプロジェクトを公開しました </Motion> </MotionProvider> );}ガイドラインと落とし穴
Section titled “ガイドラインと落とし穴”Appeal は単独の動きではなく、他の 11 原則の強さをまとめて決める層です。
- Timing —
tempoが尺を決めます。 - Exaggeration —
exaggerationが振れ幅を決めます。 - Follow Through —
bounceが戻りのオーバーシュートを決めます。 - Anticipation —
anticipationが溜めの大きさを決めます。 - Squash & Stretch —
squashが変形の強さを決めます。 - Solid Drawing — 浮上量と傾きの角度に
exaggerationが掛かります。